diff --git a/.docs/01-requirements.md b/.docs/01-requirements.md index 54088d56c..efe34fa37 100644 --- a/.docs/01-requirements.md +++ b/.docs/01-requirements.md @@ -48,7 +48,7 @@ - 상품 탐색은 **로그인 없이** 가능하다 (비회원도 조회 가능). - 고객에게 노출되는 정보와 어드민에게 노출되는 정보는 다를 수 있다. - 상품 목록은 **페이지 단위**로 제공된다. -- 정렬 기준: 최신순(기본), 가격 낮은 순, 가격 높은 순, 좋아요 많은 순. +- 정렬 기준: 최신순(기본), 가격 낮은 순, 좋아요 많은 순. --- @@ -101,6 +101,7 @@ 1. 고객이 원하는 상품들과 수량을 선택하여 주문한다. 2. 고객이 특정 기간 내 자신의 주문 목록을 조회한다. 3. 고객이 특정 주문의 상세 내역(어떤 상품을, 얼마에, 몇 개 샀는지)을 조회한다. +4. 고객이 주문을 취소한다. **드러나는 행위** @@ -110,6 +111,7 @@ - 주문 시점의 상품 정보(이름, 가격 등)가 **스냅샷으로 보존**된다. 이후 상품이 변경·삭제되어도 주문 내역은 영향받지 않는다. - 주문 목록 조회 시 **시작일과 종료일을 반드시 지정**해야 한다. - 고객은 **본인의 주문만** 조회할 수 있다. +- ORDERED 상태의 주문만 취소할 수 있다. --- @@ -175,6 +177,7 @@ | 삭제된 상품 주문 불가 | 삭제된 상품은 주문할 수 없다 | | 스냅샷 보존 | 주문 시점의 상품명, 상품 가격, 브랜드명이 주문 항목에 스냅샷으로 저장된다. 수량은 주문 항목 자체 필드이다 | | 주문 초기 상태 | 주문 생성 시 초기 상태는 ORDERED. 향후 결제 기능 추가 시 확장 가능하다 | +| 주문 취소 가능 상태 | ORDERED 상태의 주문만 취소할 수 있다. 이미 취소된 주문은 다시 취소할 수 없다 | | 주문 조회 기간 | 고객의 주문 목록 조회 시 시작일과 종료일을 반드시 지정해야 한다 | ### 3.3 좋아요 @@ -208,7 +211,7 @@ | 규칙 | 설명 | |---|---| -| 상품 정렬 기준 | 최신순, 가격 낮은 순, 가격 높은 순, 좋아요 많은 순을 지원한다 | +| 상품 정렬 기준 | 최신순, 가격 낮은 순, 좋아요 많은 순을 지원한다 | | 기본 정렬 | 정렬 기준을 지정하지 않으면 최신순으로 정렬된다 | ### 3.7 정보 노출 @@ -261,8 +264,8 @@ ### 4.5 주문 상태 정책 - 주문은 생성 시 ORDERED 상태로 시작한다. -- 결제 기능이 추가되면 PAID, CANCELLED 등의 상태로 확장한다. -- **현재 범위에서는 상태 전이 기능을 포함하지 않는다.** +- 주문 상태: ORDERED(주문 완료), CANCELLED(취소). +- 상태 전이: ORDERED → CANCELLED (주문 취소). CANCELLED 상태에서는 다른 상태로 전이할 수 없다. --- @@ -273,5 +276,4 @@ | 유저(Users) 기능 | 회원가입, 내 정보 조회, 비밀번호 변경은 이미 구현 완료 | | 결제(Payment) | 향후 별도 단계에서 추가 개발 예정 | | 쿠폰(Coupon) | 향후 별도 단계에서 추가 개발 예정 | -| 주문 상태 전이 | 결제 기능과 함께 추가. 현재는 주문 생성(ORDERED)만 다룬다 | -| 주문 취소 | 현재 범위에서 주문 취소 기능은 제공하지 않는다 | +| 주문 상태 전이 (결제 연동) | 결제 기능이 추가되면 PAID 등 추가 상태로 확장. 현재는 ORDERED → CANCELLED 전이만 다룬다 | diff --git a/.docs/02-sequence-diagrams.md b/.docs/02-sequence-diagrams.md index fe40def5a..d50767a9f 100644 --- a/.docs/02-sequence-diagrams.md +++ b/.docs/02-sequence-diagrams.md @@ -9,40 +9,45 @@ > 시나리오 2.2 — 고객이 마음에 드는 상품에 좋아요를 누른다. -> 취소도 같은 흐름이라고 생각하면 된다. +> 취소도 같은 흐름이라고 생각하면 된다. (incrementLikeCount → decrementLikeCount) ```mermaid sequenceDiagram actor 고객 participant LikeV1Controller - participant LikeFacade - participant ProductService - participant LikeService + participant LikeApplicationService + participant LikeDomainService participant Like participant LikeRepository + participant ProductDomainService + participant Product Note right of 고객: 인증된 고객 - 고객->>LikeV1Controller: 좋아요 등록 요청 - LikeV1Controller->>LikeFacade: 좋아요 등록 + 고객->>+LikeV1Controller: 좋아요 등록 요청 + LikeV1Controller->>+LikeApplicationService: 좋아요 등록 - LikeFacade->>ProductService: 상품 조회 - alt 상품이 존재하지 않거나 삭제됨 - ProductService-->>고객: 실패 - end - - LikeFacade->>LikeService: 좋아요 등록 - LikeService->>LikeRepository: 중복 좋아요 확인 + LikeApplicationService->>+LikeDomainService: 좋아요 등록 + LikeDomainService->>+LikeRepository: 중복 좋아요 확인 + LikeRepository-->>-LikeDomainService: 결과 alt 이미 좋아요한 상품 - LikeService-->>고객: 실패 + LikeDomainService-->>고객: 실패 end - LikeService->>Like: 좋아요 생성 - LikeService->>LikeRepository: 좋아요 저장 + LikeDomainService->>+Like: 좋아요 생성 + Like-->>-LikeDomainService: 좋아요 + LikeDomainService->>+LikeRepository: 좋아요 저장 + LikeRepository-->>-LikeDomainService: 완료 + + LikeDomainService-->>-LikeApplicationService: 결과 반환 - LikeService-->>LikeFacade: 결과 반환 - LikeFacade-->>LikeV1Controller: 결과 반환 - LikeV1Controller-->>고객: 성공 + LikeApplicationService->>+ProductDomainService: 좋아요 수 증가 (비관적 락) + ProductDomainService->>+Product: incrementLikeCount() + Product-->>-ProductDomainService: 완료 + ProductDomainService-->>-LikeApplicationService: 완료 + + LikeApplicationService-->>-LikeV1Controller: 결과 반환 + LikeV1Controller-->>-고객: 성공 ``` --- @@ -59,34 +64,96 @@ sequenceDiagram sequenceDiagram actor 고객 participant CartV1Controller - participant CartFacade - participant ProductService - participant CartService + participant CartApplicationService + participant ProductDomainService + participant CartDomainService participant CartItem participant CartRepository Note right of 고객: 인증된 고객 - 고객->>CartV1Controller: 장바구니 담기 요청 - CartV1Controller->>CartFacade: 장바구니 담기 + 고객->>+CartV1Controller: 장바구니 담기 요청 + CartV1Controller->>+CartApplicationService: 장바구니 담기 - CartFacade->>ProductService: 상품 조회 + CartApplicationService->>+ProductDomainService: 상품 조회 alt 상품이 존재하지 않거나 삭제됨 - ProductService-->>고객: 실패 + ProductDomainService-->>고객: 실패 end + ProductDomainService-->>-CartApplicationService: 상품 - CartFacade->>CartService: 장바구니에 상품 담기 - CartService->>CartRepository: 기존 장바구니 항목 조회 + CartApplicationService->>+CartDomainService: 장바구니에 상품 담기 + CartDomainService->>+CartRepository: 기존 장바구니 항목 조회 + CartRepository-->>-CartDomainService: 항목 alt 이미 담긴 상품 - CartService->>CartItem: 수량 합산 + CartDomainService->>+CartItem: 수량 합산 + CartItem-->>-CartDomainService: 완료 else 새로운 상품 - CartService->>CartItem: 항목 생성 + CartDomainService->>+CartItem: 항목 생성 + CartItem-->>-CartDomainService: 항목 end - CartService->>CartRepository: 저장 + CartDomainService->>+CartRepository: 저장 + CartRepository-->>-CartDomainService: 완료 - CartService-->>CartFacade: 결과 반환 - CartFacade-->>CartV1Controller: 결과 반환 - CartV1Controller-->>고객: 성공 + CartDomainService-->>-CartApplicationService: 결과 반환 + CartApplicationService-->>-CartV1Controller: 결과 반환 + CartV1Controller-->>-고객: 성공 +``` + +--- + +## 주문하기 + +> 시나리오 2.4 - 고객은 여러 상품을 한 번에 주문한다. 주문 후 자신의 주문 내역을 조회할 수 있다. + +**다이어그램이 필요한 이유** +- 조건 분기: 상품 유효성 검증, 재고 부족 검증, 중복 상품 검증 +- 도메인 간 협력: 주문이 상품과 브랜드의 상태를 확인해야 한다 +- 도메인 책임: 재고 차감은 Product, 중복 검증과 금액 계산은 OrderDomainService의 책임 + +```mermaid +sequenceDiagram + actor 고객 + participant OrderV1Controller + participant OrderApplicationService + participant ProductDomainService + participant Product + participant BrandDomainService + participant OrderDomainService + participant Order + + Note right of 고객: 인증된 고객 + + 고객->>+OrderV1Controller: 주문 요청 + OrderV1Controller->>+OrderApplicationService: 주문 요청 + + loop 각 주문 항목 (productId 순으로 정렬) + OrderApplicationService->>+ProductDomainService: 상품 조회 (비관적 락) + alt 상품이 존재하지 않거나 삭제됨 + ProductDomainService-->>고객: 실패 + end + ProductDomainService-->>-OrderApplicationService: 상품 + OrderApplicationService->>+Product: 재고 차감 + alt 재고 부족 + Product-->>고객: 실패 + end + Product-->>-OrderApplicationService: 완료 + end + + OrderApplicationService->>+BrandDomainService: 브랜드 정보 조회 (스냅샷용) + BrandDomainService-->>-OrderApplicationService: 브랜드 목록 + + OrderApplicationService->>+OrderDomainService: 주문 생성 (스냅샷 데이터 전달) + OrderDomainService->>OrderDomainService: 중복 상품 검증 + alt 중복 상품 존재 + OrderDomainService-->>고객: 실패 + end + OrderDomainService->>OrderDomainService: 총 금액 계산 + OrderDomainService->>+Order: 주문 및 주문 항목 생성 + Order-->>-OrderDomainService: 주문 + + OrderDomainService-->>-OrderApplicationService: 결과 반환 + OrderApplicationService-->>-OrderV1Controller: 결과 반환 + OrderV1Controller-->>-고객: 성공 ``` --- @@ -96,7 +163,7 @@ sequenceDiagram > 시나리오 2.3 / 2.4 — 고객이 장바구니의 모든 항목을 한 번에 주문한다. **다이어그램이 필요한 이유** -- 도메인 간 협력: Cart → Product → Order 세 도메인이 협력 +- 도메인 간 협력: Cart → Product → Brand → Order 네 도메인이 협력 - 조건 분기: 장바구니 비어있음, 상품 유효성, 재고 부족 - 주문 성공 후 장바구니 비우기까지 하나의 트랜잭션 @@ -104,41 +171,65 @@ sequenceDiagram sequenceDiagram actor 고객 participant OrderV1Controller - participant OrderFacade - participant CartService - participant ProductService + participant OrderApplicationService + participant CartDomainService + participant ProductDomainService participant Product - participant OrderService + participant BrandDomainService + participant OrderDomainService participant Order Note right of 고객: 인증된 고객 - 고객->>OrderV1Controller: 장바구니 주문 요청 - OrderV1Controller->>OrderFacade: 장바구니 주문 + 고객->>+OrderV1Controller: 장바구니 주문 요청 + OrderV1Controller->>+OrderApplicationService: 장바구니 주문 - OrderFacade->>CartService: 장바구니 조회 + OrderApplicationService->>+CartDomainService: 장바구니 조회 + CartDomainService-->>-OrderApplicationService: 장바구니 alt 장바구니가 비어있음 - CartService-->>고객: 실패 + OrderApplicationService-->>고객: 실패 end - OrderFacade->>ProductService: 상품 유효성 확인 - alt 판매 불가 상품 존재 - ProductService-->>고객: 실패 + OrderApplicationService->>+ProductDomainService: 장바구니 상품 일괄 조회 + ProductDomainService-->>-OrderApplicationService: 유효한 상품 목록 + + alt 유효하지 않은 상품이 포함됨 + OrderApplicationService->>+CartDomainService: 유효하지 않은 상품 제거 + CartDomainService-->>-OrderApplicationService: 완료 + alt 유효한 상품이 하나도 없음 + OrderApplicationService-->>고객: 실패 + end end - OrderFacade->>ProductService: 재고 확인 및 차감 - ProductService->>Product: 재고 차감 - alt 재고 부족 - ProductService-->>고객: 실패 + loop 각 장바구니 항목 (productId 순으로 정렬) + OrderApplicationService->>+ProductDomainService: 상품 조회 (비관적 락) + alt 상품이 존재하지 않거나 삭제됨 + ProductDomainService-->>고객: 실패 + end + ProductDomainService-->>-OrderApplicationService: 상품 + OrderApplicationService->>+Product: 재고 차감 + alt 재고 부족 + Product-->>고객: 실패 + end + Product-->>-OrderApplicationService: 완료 end - OrderFacade->>OrderService: 주문 생성 (스냅샷 포함) - OrderService->>Order: 주문 생성 + OrderApplicationService->>+BrandDomainService: 브랜드 정보 조회 (스냅샷용) + BrandDomainService-->>-OrderApplicationService: 브랜드 목록 + + OrderApplicationService->>+OrderDomainService: 주문 생성 (스냅샷 데이터 전달) + OrderDomainService->>OrderDomainService: 중복 상품 검증 + OrderDomainService->>OrderDomainService: 총 금액 계산 + OrderDomainService->>+Order: 주문 및 주문 항목 생성 + Order-->>-OrderDomainService: 주문 - OrderFacade->>CartService: 장바구니 비우기 + OrderDomainService-->>-OrderApplicationService: 결과 반환 - OrderFacade-->>OrderV1Controller: 결과 반환 - OrderV1Controller-->>고객: 성공 + OrderApplicationService->>+CartDomainService: 장바구니 비우기 + CartDomainService-->>-OrderApplicationService: 완료 + + OrderApplicationService-->>-OrderV1Controller: 결과 반환 + OrderV1Controller-->>-고객: 성공 ``` --- @@ -149,79 +240,76 @@ sequenceDiagram **다이어그램이 필요한 이유** - 도메인 간 협력: Brand 삭제가 Product 연쇄 삭제를 트리거한다 -- 삭제 순서: 상품을 먼저 삭제한 뒤 브랜드를 삭제해야 정합성이 유지된다 +- 삭제 순서: 브랜드를 먼저 삭제한 뒤 해당 브랜드의 상품을 삭제한다 ```mermaid sequenceDiagram actor 어드민 participant AdminBrandV1Controller - participant BrandFacade - participant BrandService - participant ProductService + participant BrandApplicationService + participant BrandDomainService + participant ProductDomainService participant Brand participant Product Note right of 어드민: 인증된 어드민 - 어드민->>AdminBrandV1Controller: 브랜드 삭제 요청 - AdminBrandV1Controller->>BrandFacade: 브랜드 삭제 + 어드민->>+AdminBrandV1Controller: 브랜드 삭제 요청 + AdminBrandV1Controller->>+BrandApplicationService: 브랜드 삭제 - BrandFacade->>BrandService: 브랜드 조회 + BrandApplicationService->>+BrandDomainService: 브랜드 삭제 + BrandDomainService->>BrandDomainService: 브랜드 조회 alt 브랜드가 존재하지 않거나 삭제됨 - BrandService-->>어드민: 실패 + BrandDomainService-->>어드민: 실패 end + BrandDomainService->>+Brand: 논리 삭제 (soft delete) + Brand-->>-BrandDomainService: 완료 + BrandDomainService-->>-BrandApplicationService: 완료 - BrandFacade->>ProductService: 해당 브랜드의 상품 전체 삭제 - ProductService->>Product: 논리 삭제 (soft delete) - - BrandFacade->>BrandService: 브랜드 삭제 - BrandService->>Brand: 논리 삭제 (soft delete) + BrandApplicationService->>+ProductDomainService: 해당 브랜드의 상품 전체 삭제 + ProductDomainService->>+Product: 논리 삭제 (soft delete) + Product-->>-ProductDomainService: 완료 + ProductDomainService-->>-BrandApplicationService: 완료 - BrandFacade-->>AdminBrandV1Controller: 결과 반환 - AdminBrandV1Controller-->>어드민: 성공 + BrandApplicationService-->>-AdminBrandV1Controller: 결과 반환 + AdminBrandV1Controller-->>-어드민: 성공 ``` --- -### 주문하기 +## 주문 취소 -> 시나리오 2.4 - 고객은 여러 상품을 한 번에 주문한다. 주문 후 자신의 주문 내역을 조회할 수 있다. +> 시나리오 2.4 — 고객이 주문을 취소한다. **다이어그램이 필요한 이유** -- 조건 분기: 상품 유효성 검증, 재고 부족 검증 -- 도메인 간 협력: 주문이 상품의 상태/재고를 확인해야 한다 -- 도메인 책임: 가격 정보 제공은 Product, 금액 계산은 Order의 책임 +- 조건 분기: 주문 상태에 따른 취소 가능 여부 검증 +- 도메인 로직: ORDERED 상태에서만 CANCELLED로 전이 가능 ```mermaid sequenceDiagram actor 고객 participant OrderV1Controller - participant OrderFacade - participant ProductService - participant Product - participant OrderService + participant OrderApplicationService + participant OrderDomainService participant Order Note right of 고객: 인증된 고객 - 고객->>OrderV1Controller: 주문 요청 - OrderV1Controller->>OrderFacade: 주문 요청 + 고객->>+OrderV1Controller: 주문 취소 요청 + OrderV1Controller->>+OrderApplicationService: 주문 취소 - OrderFacade->>ProductService: 상품 유효성 확인 - alt 판매 불가 상품 존재 - ProductService-->>고객: 실패 + OrderApplicationService->>+OrderDomainService: 주문 조회 (본인 확인) + alt 주문이 존재하지 않거나 본인의 주문이 아님 + OrderDomainService-->>고객: 실패 end + OrderDomainService-->>-OrderApplicationService: 주문 - OrderFacade->>ProductService: 재고 확인 및 차감 - ProductService->>Product: 재고 차감 - alt 재고 부족 - ProductService-->>고객: 실패 + OrderApplicationService->>+Order: cancel() + alt ORDERED 상태가 아님 + Order-->>고객: 실패 end + Order-->>-OrderApplicationService: 완료 - OrderFacade->>OrderService: 주문 생성 - OrderService->>Order: 주문 생성 (스냅샷 포함) - - OrderService-->>OrderFacade: 결과 반환 - OrderFacade-->>OrderV1Controller: 결과 반환 - OrderV1Controller-->>고객: 성공 -``` \ No newline at end of file + OrderApplicationService-->>-OrderV1Controller: 결과 반환 + OrderV1Controller-->>-고객: 성공 +``` diff --git a/.docs/03-class-diagram.md b/.docs/03-class-diagram.md index a5d774d5d..6496917ff 100644 --- a/.docs/03-class-diagram.md +++ b/.docs/03-class-diagram.md @@ -14,12 +14,13 @@ classDiagram UserName name LocalDate birthDate Email email - +changePassword(Password) void + +changePassword(String) void + +getMaskedName() String } class Brand { String name - +update(String) void + +rename(String) void } class Product { @@ -28,10 +29,10 @@ classDiagram Money price Stock stock int likeCount - +update(String, Money, Stock) void + +changeDetails(String, Money, Stock) void +deductStock(int) void - +addLikeCount() void - +subtractLikeCount() void + +incrementLikeCount() void + +decrementLikeCount() void } class Like { @@ -39,22 +40,32 @@ classDiagram Long productId } - class CartItem { + class Cart { Long userId + List~CartItem~ items + +addItem(Long, int) void + +removeItem(Long) void + +updateItemQuantity(Long, int) void + +clear() void + +removeUnavailableItems(Set~Long~) void + } + + class CartItem { Long productId - Quantity quantity + int quantity +addQuantity(int) void - +updateQuantity(int) void + +changeQuantity(int) void } class Order { Long userId Money totalPrice OrderStatus status + +addItems(List~OrderItemCommand~) void + +cancel() void } class OrderItem { - Long orderId Long productId String productName Money productPrice @@ -65,15 +76,17 @@ classDiagram class OrderStatus { <> ORDERED + CANCELLED } Product "*" --> "1" Brand : brandId Like "*" --> "1" User : userId Like "*" --> "1" Product : productId - CartItem "*" --> "1" User : userId + User "1" --> "1" Cart : userId + Cart "1" --> "*" CartItem : items CartItem "*" --> "1" Product : productId Order "*" --> "1" User : userId - OrderItem "*" --> "1" Order : orderId + Order "1" --> "*" OrderItem : items Order --> OrderStatus ``` @@ -90,6 +103,10 @@ classDiagram | UserName | mask() | 마지막 글자를 `*`로 마스킹 | | Email | validate() | 이메일 포맷 검증 | | Money | validate() | 0 이상이어야 함 | +| Money | plus(Money) | 두 금액의 합산 | +| Money | minus(Money) | 금액 차감, 결과가 음수면 불가 | +| Money | multiply(int) | 금액 × 수량 | +| Money | isGreaterThanOrEqual(Money) | 금액 비교 | | Stock | validate() | 0 이상이어야 함 | | Stock | deduct(quantity) | 재고 부족 시 CoreException(BAD_REQUEST) | | Quantity | validate() | 1 이상이어야 함 | @@ -101,11 +118,19 @@ classDiagram | 엔티티 | 메서드 | 비즈니스 규칙 | |---|---|---| -| User | changePassword(Password) | 새 Password VO로 교체 | +| User | changePassword(String) | 암호화된 비밀번호로 교체 | +| User | getMaskedName() | UserName VO의 mask()를 통해 마스킹된 이름 반환 | +| Brand | rename(String) | 브랜드명 변경 | +| Product | changeDetails(String, Money, Stock) | 상품 정보(이름, 가격, 재고) 변경 | | Product | deductStock(int) | 재고 부족 시 CoreException(BAD_REQUEST) | -| Product | addLikeCount() / subtractLikeCount() | 좋아요 등록/취소 시 카운터 증감 | +| Product | incrementLikeCount() | 좋아요 수 1 증가 | +| Product | decrementLikeCount() | 좋아요 수 1 감소, 0 미만 불가 | +| Cart | addItem(Long, int) | 이미 담긴 상품이면 수량 합산, 새 상품이면 항목 추가 | +| Cart | removeUnavailableItems(Set<Long>) | 유효하지 않은 상품을 장바구니에서 제거 | | CartItem | addQuantity(int) | 이미 담긴 상품 → 수량 합산 | -| CartItem | updateQuantity(int) | 수량 변경, 0 이하 불가 | +| CartItem | changeQuantity(int) | 수량 변경, 0 이하 불가 | +| Order | addItems(List<OrderItemCommand>) | 주문 항목 추가 | +| Order | cancel() | ORDERED 상태에서만 CANCELLED로 전이. 그 외 상태에서는 CoreException(BAD_REQUEST) | --- @@ -116,19 +141,22 @@ classDiagram | Brand → Product | 1 : N | 하나의 브랜드에 여러 상품 | | User → Like | 1 : N | 한 유저가 여러 좋아요 | | Product → Like | 1 : N | 한 상품에 여러 좋아요 (Like = 교차 테이블) | -| User → CartItem | 1 : N | 한 유저의 장바구니 항목들 | +| User → Cart | 1 : 1 | 한 유저에 하나의 장바구니 | +| Cart → CartItem | 1 : N | 한 장바구니에 여러 항목 (Aggregate 내부) | | Product → CartItem | 1 : N | 한 상품이 여러 장바구니에 담김 | | User → Order | 1 : N | 한 유저가 여러 주문 | -| Order → OrderItem | 1 : N | 한 주문에 여러 주문 항목 | +| Order → OrderItem | 1 : N | 한 주문에 여러 주문 항목 (Aggregate 내부) | --- ## 설계 결정 -- **Rich Domain Model**: 비즈니스 로직은 엔티티 메서드에 포함한다. Facade는 오케스트레이션만 담당한다. +- **Rich Domain Model**: 비즈니스 로직은 엔티티 메서드에 포함한다. Application Service는 오케스트레이션만 담당한다. - **FK 미사용**: 모든 관계는 ID 참조만. FK 제약조건 없음. 참조 무결성은 애플리케이션 레벨에서 검증한다. -- **Cart 엔티티 없음**: CartItem만 사용. User가 곧 Cart 소유자이다. -- **좋아요 수 비정규화**: Product에 likeCount 필드로 저장. 좋아요 등록/취소 시 카운터를 증감한다. -- **N:M 관계**: Like, CartItem 교차 테이블로 해소한다. +- **Cart Aggregate Root**: Cart가 Aggregate Root이며, CartItem은 Cart 내부 엔티티이다. User당 하나의 Cart가 존재하며, CartItem 조작은 Cart를 통해서만 이루어진다. Aggregate 내부에서 `@OneToMany`/`@ManyToOne` 매핑을 사용한다 (Aggregate 경계 내 일관성 보장을 위해). +- **Order Aggregate Root**: Order가 Aggregate Root이며, OrderItem은 Order 내부 엔티티이다. Aggregate 내부에서 `@OneToMany`/`@ManyToOne` 매핑을 사용한다. +- **좋아요 수 비정규화**: Product에 likeCount 필드로 저장. LikeApplicationService에서 좋아요 등록/취소 시 비관적 락으로 Product를 조회한 뒤 in-memory에서 카운터를 증감한다. +- **N:M 관계**: Like 교차 테이블로 해소한다. - **likes, cart_items 물리 삭제**: 이력이 필요 없는 토글/임시 데이터이므로 Soft Delete 대신 물리 삭제 처리. UNIQUE 제약조건과의 충돌을 방지한다. +- **동일 상품 중복 방지**: 장바구니의 동일 상품 중복은 애플리케이션 레벨에서 검증한다 (Cart.addItem()에서 기존 항목이면 수량 합산). - **order_items의 deleted_at 유지**: 주문 항목은 삭제 시나리오가 없으나, BaseEntity 상속 일관성을 위해 deleted_at을 유지한다. diff --git a/.docs/04-erd.md b/.docs/04-erd.md index 6bc23938e..55f8e2676 100644 --- a/.docs/04-erd.md +++ b/.docs/04-erd.md @@ -47,9 +47,15 @@ erDiagram timestamp created_at } + carts { + bigint id PK + bigint user_id UK + timestamp created_at + } + cart_items { bigint id PK - bigint user_id + bigint cart_id bigint product_id int quantity timestamp created_at @@ -81,7 +87,8 @@ erDiagram brands ||--o{ products : "" users ||--o{ likes : "" products ||--o{ likes : "" - users ||--o{ cart_items : "" + users ||--|| carts : "" + carts ||--o{ cart_items : "" products ||--o{ cart_items : "" users ||--o{ orders : "" orders ||--|{ order_items : "" @@ -95,7 +102,7 @@ erDiagram |---|---|---| | users | UNIQUE(login_id) | 로그인 ID 중복 방지 | | likes | UNIQUE(user_id, product_id) | 1인 1좋아요 보장 | -| cart_items | UNIQUE(user_id, product_id) | 동일 상품 중복 담기 방지 (수량 합산으로 처리) | +| carts | UNIQUE(user_id) | 1인 1장바구니 보장 | --- @@ -105,7 +112,7 @@ erDiagram |---|---|---| | products | brand_id | 브랜드별 상품 필터링 | | likes | user_id | 유저의 좋아요 목록 조회 | -| cart_items | user_id | 유저의 장바구니 조회 | +| cart_items | cart_id | 장바구니의 항목 조회 | | orders | (user_id, created_at) | 유저의 주문 목록 조회 (날짜 범위 필터링) | | order_items | order_id | 주문의 상세 항목 조회 | @@ -117,7 +124,7 @@ erDiagram - **Soft Delete** — 모든 테이블에 deleted_at 컬럼으로 논리 삭제. 물리적으로 데이터를 제거하지 않는다. - **Soft Delete 예외** — likes, cart_items는 이력이 필요 없는 토글/임시 데이터이므로 물리 삭제(Hard Delete). UNIQUE 제약조건과의 충돌을 방지한다. - **공통 컬럼** — 모든 테이블에 BaseEntity 공통 컬럼(id, created_at, updated_at, deleted_at) 포함. -- **Enum 저장** — OrderStatus 등 Enum은 VARCHAR로 저장한다. +- **Enum 저장** — OrderStatus(ORDERED, CANCELLED) 등 Enum은 VARCHAR로 저장한다. --- @@ -126,7 +133,7 @@ erDiagram | 대상 | 방식 | 이유 | |---|---|---| | Product.stock | 비관적 락 | 주문 시 재고 차감. 동시 주문에도 재고가 음수가 되어서는 안 된다 | -| Product.like_count | 원자적 UPDATE (`SET like_count = like_count + 1`) | 좋아요 등록/취소 시 카운터 증감. 재고와 달리 경합이 심하지 않으므로 비관적 락은 과도함 | +| Product.like_count | 비관적 락 + in-memory 증감 | 좋아요 등록/취소 시 비관적 락으로 Product를 조회한 뒤 incrementLikeCount()/decrementLikeCount()로 카운터를 증감한다 | --- diff --git a/.docs/api-spec.md b/.docs/api-spec.md new file mode 100644 index 000000000..39b55014f --- /dev/null +++ b/.docs/api-spec.md @@ -0,0 +1,128 @@ +## ✅ API 제안사항 + +- 대고객 기능은 `/api/v1` prefix 를 통해 제공합니다. + + ```markdown + 유저 로그인이 필요한 기능은 아래 헤더를 통해 유저를 식별해 제공합니다. + 인증/인가는 주요 스코프가 아니므로 구현하지 않습니다. + 유저는 타 유저의 정보에 직접 접근할 수 없습니다. + + * **X-Loopers-LoginId** : 로그인 ID + * **X-Loopers-LoginPw** : 비밀번호 + ``` + +- 어드민 기능은 `/api-admin/v1` prefix 를 통해 제공합니다. + + ```markdown + 어드민 기능은 아래 헤더를 통해 어드민을 식별해 제공합니다. + + * **X-Loopers-Ldap** : loopers.admin + + LDAP : Lightweight Directory Access Protocol + 중앙 집중형 사용자 인증, 정보 검색, 액세스 제어. + -> 회사 사내 어드민 + ``` + + +## ✅ 요구사항 + +## 👤 유저 (Users) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/users` | X | 회원가입 | +| GET | `/api/v1/users/me` | O | 내 정보 조회 | +| PUT | `/api/v1/users/password` | O | 비밀번호 변경 | + +--- + +## 🏷 브랜드 & 상품 (Brands / Products) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api/v1/brands/{brandId}` | X | 브랜드 정보 조회 | +| GET | `/api/v1/products` | X | 상품 목록 조회 | +| GET | `/api/v1/products/{productId}` | X | 상품 정보 조회 | + +### ✅ 상품 목록 조회 쿼리 파라미터 + +| **파라미터** | **예시** | **설명** | +| --- | --- | --- | +| `brandId` | `1` | 특정 브랜드의 상품만 필터링 | +| `sort` | `latest` / `price_asc` / `likes_desc` | 정렬 기준 | +| `page` | `0` | 페이지 번호 (기본값 0) | +| `size` | `20` | 페이지당 상품 수 (기본값 20) | + +> 💡 정렬 기준은 선택 구현입니다. +> +> +> 필수는 `latest`, 그 외는 `price_asc`, `likes_desc` 정도로 제한해도 충분합니다. +> + +--- + +## 🏷 브랜드 & 상품 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/brands?page=0&size=20` | O | **등록된 브랜드 목록 조회** | +| GET | `/api-admin/v1/brands/{brandId}` | O | **브랜드 상세 조회** | +| POST | `/api-admin/v1/brands` | O | **브랜드 등록** | +| PUT | `/api-admin/v1/brands/{brandId}` | O | **브랜드 정보 수정** | +| DELETE | `/api-admin/v1/brands/{brandId}` | O | **브랜드 삭제** +* 브랜드 제거 시, 해당 브랜드의 상품들도 삭제되어야 함 | +| GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | O | **등록된 상품 목록 조회** | +| GET | `/api-admin/v1/products/{productId}` | O | **상품 상세 조회** | +| POST | `/api-admin/v1/products` | O | **상품 등록** +* 상품의 브랜드는 이미 등록된 브랜드여야 함 | +| PUT | `/api-admin/v1/products/{productId}` | O | **상품 정보 수정** +* 상품의 브랜드는 수정할 수 없음 | +| DELETE | `/api-admin/v1/products/{productId}` | O | **상품 삭제** | + +> 상품, 브랜드 정보 중 고객과 어드민에게 제공되어야 할 정보에 대해 고민해보세요. +> + +--- + +## ❤️ 좋아요 (Likes) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 등록 | +| DELETE | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 취소 | +| GET | `/api/v1/likes` | O | 내가 좋아요 한 상품 목록 조회 | + +--- + +## 🧾 주문 (Orders) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/orders` | O | 주문 요청 | +| GET | `/api/v1/orders?startAt=2026-01-31&endAt=2026-02-10` | O | 유저의 주문 목록 조회 | +| GET | `/api/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +**요청 예시:** + +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +> **결제**는 과정 진행 중, **추가로 개발**하게 됩니다! +**주문 정보**에는 당시의 상품 정보가 스냅샷으로 저장되어야 합니다. +**주문 시에 다음 동작이 보장되어야 합니다 :** 상품 재고 확인 및 차감 +> + +--- + +## 🧾 주문 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/orders?page=0&size=20` | O | 주문 목록 조회 | +| GET | `/api-admin/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java new file mode 100644 index 000000000..d42dfec46 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java @@ -0,0 +1,58 @@ +package com.loopers.application.brand; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.product.ProductDomainService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Set; + +@RequiredArgsConstructor +@Component +public class BrandApplicationService { + + private final BrandDomainService brandService; + private final ProductDomainService productService; + + @Transactional + public Brand register(String name) { + return brandService.register(name); + } + + @Transactional(readOnly = true) + public Brand getById(Long id) { + return brandService.getById(id); + } + + @Transactional(readOnly = true) + public Map getByIds(Set ids) { + return brandService.getByIds(ids); + } + + @Transactional(readOnly = true) + public PageResult getAll(int page, int size) { + return brandService.getAll(page, size); + } + + @Transactional + public Brand update(Long id, String name) { + return brandService.update(id, name); + } + + /** + * 단일 트랜잭션에서 Product aggregate와 Brand aggregate를 함께 수정한다. + * "하나의 트랜잭션 = 하나의 Aggregate" 원칙의 의도적 예외: + * 브랜드 삭제 시 소속 상품이 남아있으면 orphan 데이터가 발생하므로 + * 참조 무결성을 위해 원자적으로 처리한다. + * 추후 Event로 처리 + */ + @Transactional + public void delete(Long id) { + brandService.delete(id); + productService.deleteAllByBrandId(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java new file mode 100644 index 000000000..5ab98254e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java @@ -0,0 +1,74 @@ +package com.loopers.application.cart; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.cart.Cart; +import com.loopers.domain.cart.CartDomainService; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDomainService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class CartApplicationService { + + private final CartDomainService cartService; + private final ProductDomainService productService; + private final BrandDomainService brandService; + + @Transactional + public void addToCart(Long userId, Long productId, int quantity) { + productService.getById(productId); + cartService.addToCart(userId, productId, quantity); + } + + @Transactional(readOnly = true) + public Cart getMyCart(Long userId) { + return cartService.getCart(userId); + } + + @Transactional(readOnly = true) + public List getMyCartWithDetails(Long userId) { + Cart cart = cartService.getCart(userId); + List cartItems = cart.getItems(); + + Set productIds = cartItems.stream() + .map(CartItem::getProductId) + .collect(Collectors.toSet()); + Map productMap = productService.getByIds(productIds); + + Set brandIds = productMap.values().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + return cartItems.stream() + .filter(cartItem -> productMap.containsKey(cartItem.getProductId())) + .map(cartItem -> { + Product product = productMap.get(cartItem.getProductId()); + Brand brand = brandMap.get(product.getBrandId()); + return brand != null ? new CartItemDetail(cartItem, product, brand) : null; + }) + .filter(Objects::nonNull) + .toList(); + } + + @Transactional + public void updateQuantity(Long cartItemId, Long userId, int quantity) { + cartService.updateItemQuantity(userId, cartItemId, quantity); + } + + @Transactional + public void removeItem(Long cartItemId, Long userId) { + cartService.removeItem(userId, cartItemId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartItemDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartItemDetail.java new file mode 100644 index 000000000..cb5fdbbd5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartItemDetail.java @@ -0,0 +1,8 @@ +package com.loopers.application.cart; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.product.Product; + +public record CartItemDetail(CartItem cartItem, Product product, Brand brand) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java new file mode 100644 index 000000000..9af1b83e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java @@ -0,0 +1,81 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeDomainService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDomainService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeApplicationService { + + private final LikeDomainService likeService; + private final ProductDomainService productService; + private final BrandDomainService brandService; + + /** + * 단일 트랜잭션에서 Like aggregate와 Product aggregate를 함께 수정한다. + * "하나의 트랜잭션 = 하나의 Aggregate" 원칙의 의도적 예외: + * likeCount는 비정규화 카운터이며, Like 엔티티가 source of truth이다. + * 일관성과 단순성을 위해 동일 트랜잭션에서 원자적으로 처리한다. + */ + @Transactional + public void like(Long userId, Long productId) { + likeService.like(userId, productId); + productService.incrementLikeCount(productId); + } + + /** + * 단일 트랜잭션에서 Like aggregate와 Product aggregate를 함께 수정한다. + * "하나의 트랜잭션 = 하나의 Aggregate" 원칙의 의도적 예외: + * likeCount는 비정규화 카운터이며, Like 엔티티가 source of truth이다. + * 일관성과 단순성을 위해 동일 트랜잭션에서 원자적으로 처리한다. + */ + @Transactional + public void unlike(Long userId, Long productId) { + likeService.unlike(userId, productId); + productService.decrementLikeCount(productId); + } + + @Transactional(readOnly = true) + public List getMyLikes(Long userId) { + return likeService.getMyLikes(userId); + } + + @Transactional(readOnly = true) + public List getMyLikesWithDetails(Long userId) { + List likes = likeService.getMyLikes(userId); + + Set productIds = likes.stream() + .map(Like::getProductId) + .collect(Collectors.toSet()); + Map productMap = productService.getByIds(productIds); + + Set brandIds = productMap.values().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + return likes.stream() + .filter(like -> { + Product product = productMap.get(like.getProductId()); + return product != null && brandMap.containsKey(product.getBrandId()); + }) + .map(like -> { + Product product = productMap.get(like.getProductId()); + Brand brand = brandMap.get(product.getBrandId()); + return new LikedProductDetail(like, product, brand); + }) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductDetail.java new file mode 100644 index 000000000..5514128c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductDetail.java @@ -0,0 +1,8 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; + +public record LikedProductDetail(Like like, Product product, Brand brand) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java new file mode 100644 index 000000000..4ba22bc85 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java @@ -0,0 +1,29 @@ +package com.loopers.application.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.List; +import java.util.Objects; + +public record CreateOrderCommand( + Long userId, + List items +) { + + public CreateOrderCommand { + Objects.requireNonNull(userId, "유저 ID는 필수입니다."); + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 하나 이상이어야 합니다."); + } + } + + public record LineItem(Long productId, int quantity) { + public LineItem { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java new file mode 100644 index 000000000..579b416ea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java @@ -0,0 +1,154 @@ +package com.loopers.application.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.cart.Cart; +import com.loopers.domain.cart.CartDomainService; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderDomainService; +import com.loopers.domain.order.OrderItemCommand; +import com.loopers.domain.order.OrderPolicy; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderApplicationService { + + private final OrderDomainService orderService; + private final ProductDomainService productService; + private final BrandDomainService brandService; + private final CartDomainService cartService; + + @Transactional + public Order createOrder(CreateOrderCommand command) { + return processOrder(command.userId(), command.items()); + } + + /** + * 단일 트랜잭션에서 Cart aggregate(비우기), Product aggregate(재고 차감), + * Order aggregate(생성)를 함께 수정한다. + * "하나의 트랜잭션 = 하나의 Aggregate" 원칙의 의도적 예외: + * 장바구니 기반 주문 시 재고 차감, 주문 생성, 장바구니 비우기를 + * 원자적으로 처리하여 일관성을 보장한다. + */ + @Transactional + public Order createOrderFromCart(Long userId) { + Cart cart = cartService.getCart(userId); + List cartItems = cart.getItems(); + if (cartItems.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "장바구니가 비어있습니다."); + } + + Set cartProductIds = cartItems.stream() + .map(CartItem::getProductId) + .collect(Collectors.toSet()); + Map productMap = productService.getByIds(cartProductIds); + Set availableProductIds = productMap.keySet(); + + if (!availableProductIds.containsAll(cartProductIds)) { + cartService.removeUnavailableItems(cart, availableProductIds); + cartItems = cartItems.stream() + .filter(ci -> availableProductIds.contains(ci.getProductId())) + .toList(); + if (cartItems.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "장바구니의 모든 상품이 더 이상 존재하지 않습니다."); + } + } + + List lineItems = cartItems.stream() + .map(ci -> new CreateOrderCommand.LineItem(ci.getProductId(), ci.getQuantity().value())) + .toList(); + + Order order = processOrder(userId, lineItems); + cartService.clearCart(userId); + return order; + } + + @Transactional(readOnly = true) + public PageResult getMyOrders(Long userId, LocalDate startAt, LocalDate endAt, int page, int size) { + return orderService.getMyOrders(userId, startAt, endAt, page, size); + } + + @Transactional(readOnly = true) + public Order getMyOrder(Long userId, Long orderId) { + return orderService.getByIdAndUserIdWithItems(orderId, userId); + } + + @Transactional(readOnly = true) + public PageResult getAllOrders(int page, int size) { + return orderService.getAllOrders(page, size); + } + + @Transactional(readOnly = true) + public Order getOrder(Long orderId) { + return orderService.getByIdWithItems(orderId); + } + + /** + * 단일 트랜잭션에서 Product aggregate(재고 차감)와 Order aggregate(생성)를 함께 수정한다. + * "하나의 트랜잭션 = 하나의 Aggregate" 원칙의 의도적 예외: + * 재고 차감과 주문 생성은 원자적으로 처리되어야 하며, + * 분리 시 재고 불일치 또는 유령 주문이 발생할 수 있다. + */ + private Order processOrder(Long userId, List lineItems) { + // 0. 중복 상품 조기 차단 (의도적 이중 검증) + // OrderDomainService.createOrder()에도 동일 검증이 존재하나, + // Application 레벨에서 먼저 차단하여 불필요한 pessimistic lock/재고 차감 DB 호출을 방지한다. + // 도메인 레벨 검증은 다른 진입점(직접 호출 등)에 대한 안전망으로 유지. + List productIds = lineItems.stream() + .map(CreateOrderCommand.LineItem::productId).toList(); + OrderPolicy.validateNoDuplicateProducts(productIds); + + // 1. deadlock 방지를 위해 productId 기준 정렬 + List sorted = lineItems.stream() + .sorted(Comparator.comparing(CreateOrderCommand.LineItem::productId)).toList(); + + // 2. 재고 차감 (pessimistic lock) — Map으로 관리 + Map productMap = new LinkedHashMap<>(); + for (CreateOrderCommand.LineItem item : sorted) { + productMap.put(item.productId(), + productService.deductStockWithLock(item.productId(), item.quantity())); + } + + // 3. Brand 일괄 조회 (N+1 방지) + Set brandIds = productMap.values().stream() + .map(Product::getBrandId).collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + // 4. OrderItemCommand 조립 — productId 키로 안전하게 조회 + List itemCommands = new ArrayList<>(); + for (CreateOrderCommand.LineItem item : sorted) { + Product product = productMap.get(item.productId()); + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, + "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); + } + itemCommands.add(new OrderItemCommand( + product.getId(), product.getName(), product.getPrice(), + brand.getName(), item.quantity() + )); + } + + // 5. 주문 생성 (도메인 서비스 위임) + return orderService.createOrder(userId, itemCommands); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java new file mode 100644 index 000000000..2bef03620 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java @@ -0,0 +1,90 @@ +package com.loopers.application.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductApplicationService { + + private final ProductDomainService productService; + private final BrandDomainService brandService; + + @Transactional + public ProductWithBrand register(RegisterProductCommand command) { + Brand brand = brandService.getById(command.brandId()); + Product product = productService.register(command.brandId(), command.name(), command.price(), command.stock()); + return new ProductWithBrand(product, brand); + } + + @Transactional(readOnly = true) + public ProductWithBrand getProductWithBrand(Long id) { + Product product = productService.getById(id); + Brand brand = brandService.getById(product.getBrandId()); + return new ProductWithBrand(product, brand); + } + + @Transactional(readOnly = true) + public Product getById(Long id) { + return productService.getById(id); + } + + @Transactional(readOnly = true) + public Map getByIds(Set ids) { + return productService.getByIds(ids); + } + + @Transactional(readOnly = true) + public ProductPageWithBrands getAll(Long brandId, ProductSortType sort, int page, int size) { + PageResult result = productService.getAll(brandId, sort, page, size); + Set brandIds = result.items().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + return new ProductPageWithBrands(result, brandMap); + } + + @Transactional(readOnly = true) + public ProductPageWithBrands getAllForAdmin(Long brandId, ProductSortType sort, int page, int size) { + PageResult result = productService.getAll(brandId, sort, page, size); + Set brandIds = result.items().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + for (Product product : result.items()) { + if (!brandMap.containsKey(product.getBrandId())) { + throw new CoreException(ErrorType.INTERNAL_ERROR, + "브랜드를 찾을 수 없습니다. productId=" + product.getId() + ", brandId=" + product.getBrandId()); + } + } + + return new ProductPageWithBrands(result, brandMap); + } + + @Transactional + public ProductWithBrand update(UpdateProductCommand command) { + Product product = productService.update(command.productId(), command.name(), command.price(), command.stock()); + Brand brand = brandService.getById(product.getBrandId()); + return new ProductWithBrand(product, brand); + } + + @Transactional + public void delete(Long id) { + productService.delete(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageWithBrands.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageWithBrands.java new file mode 100644 index 000000000..26b39b6a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageWithBrands.java @@ -0,0 +1,10 @@ +package com.loopers.application.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +import java.util.Map; + +public record ProductPageWithBrands(PageResult result, Map brandMap) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductWithBrand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductWithBrand.java new file mode 100644 index 000000000..095a1814a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductWithBrand.java @@ -0,0 +1,7 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +public record ProductWithBrand(Product product, Brand brand) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/RegisterProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/RegisterProductCommand.java new file mode 100644 index 000000000..ec86455e0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/RegisterProductCommand.java @@ -0,0 +1,15 @@ +package com.loopers.application.product; + +import org.springframework.util.Assert; + +import java.util.Objects; + +public record RegisterProductCommand(Long brandId, String name, int price, int stock) { + + public RegisterProductCommand { + Objects.requireNonNull(brandId, "브랜드 ID는 필수입니다."); + Assert.hasText(name, "상품 이름은 비어있을 수 없습니다."); + Assert.state(price >= 0, "상품 가격은 0 이상이어야 합니다."); + Assert.state(stock >= 0, "상품 재고는 0 이상이어야 합니다."); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductCommand.java new file mode 100644 index 000000000..cbb9b6566 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductCommand.java @@ -0,0 +1,15 @@ +package com.loopers.application.product; + +import org.springframework.util.Assert; + +import java.util.Objects; + +public record UpdateProductCommand(Long productId, String name, int price, int stock) { + + public UpdateProductCommand { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + Assert.hasText(name, "상품 이름은 비어있을 수 없습니다."); + Assert.state(price >= 0, "상품 가격은 0 이상이어야 합니다."); + Assert.state(stock >= 0, "상품 재고는 0 이상이어야 합니다."); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java new file mode 100644 index 000000000..9c74a1f90 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java @@ -0,0 +1,36 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserDomainService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class UserApplicationService { + + private final UserDomainService userService; + + @Transactional(readOnly = true) + public User getById(Long id) { + return userService.getById(id); + } + + @Transactional + public User signup(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + return userService.signup(loginId, rawPassword, name, birthDate, email); + } + + @Transactional(readOnly = true) + public User authenticate(String loginId, String rawPassword) { + return userService.authenticate(loginId, rawPassword); + } + + @Transactional + public void changePassword(Long userId, String currentPassword, String newPassword) { + userService.changePassword(userId, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java deleted file mode 100644 index f7de628d2..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; - -@RequiredArgsConstructor -@Component -public class UserFacade { - - private final UserService userService; - - @Transactional - public UserInfo signup(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { - User user = userService.signup(loginId, rawPassword, name, birthDate, email); - return UserInfo.from(user); - } - - public UserInfo getMyInfo(User user) { - return UserInfo.fromWithMaskedName(user); - } - - @Transactional - public void changePassword(User user, String currentPassword, String newPassword) { - userService.changePassword(user, currentPassword, newPassword); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java deleted file mode 100644 index dfaa81f00..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.User; - -import java.time.LocalDate; - -public record UserInfo(Long id, String loginId, String name, LocalDate birthDate, String email) { - public static UserInfo from(User user) { - return new UserInfo( - user.getId(), - user.getLoginId(), - user.getName(), - user.getBirthDate(), - user.getEmail() - ); - } - - public static UserInfo fromWithMaskedName(User user) { - return new UserInfo( - user.getId(), - user.getLoginId(), - user.getMaskedName(), - user.getBirthDate(), - user.getEmail() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java index 91f4b12fc..ca70bf04c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java @@ -1,8 +1,18 @@ package com.loopers.config; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.cart.CartDomainService; +import com.loopers.domain.cart.CartRepository; +import com.loopers.domain.like.LikeDomainService; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.order.OrderDomainService; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.PasswordEncryptor; +import com.loopers.domain.user.UserDomainService; import com.loopers.domain.user.UserRepository; -import com.loopers.domain.user.UserService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -10,7 +20,32 @@ public class DomainServiceConfig { @Bean - public UserService userService(UserRepository userRepository, PasswordEncryptor passwordEncryptor) { - return new UserService(userRepository, passwordEncryptor); + public UserDomainService userDomainService(UserRepository userRepository, PasswordEncryptor passwordEncryptor) { + return new UserDomainService(userRepository, passwordEncryptor); + } + + @Bean + public BrandDomainService brandDomainService(BrandRepository brandRepository) { + return new BrandDomainService(brandRepository); + } + + @Bean + public ProductDomainService productDomainService(ProductRepository productRepository) { + return new ProductDomainService(productRepository); + } + + @Bean + public LikeDomainService likeDomainService(LikeRepository likeRepository) { + return new LikeDomainService(likeRepository); + } + + @Bean + public CartDomainService cartDomainService(CartRepository cartRepository) { + return new CartDomainService(cartRepository); + } + + @Bean + public OrderDomainService orderDomainService(OrderRepository orderRepository) { + return new OrderDomainService(orderRepository); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/PageResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/PageResult.java new file mode 100644 index 000000000..1a2acb29e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/PageResult.java @@ -0,0 +1,12 @@ +package com.loopers.domain; + +import java.util.List; +import java.util.function.Function; + +public record PageResult(List items, int page, int size, long totalElements, int totalPages) { + + public PageResult map(Function mapper) { + List mappedItems = items.stream().map(mapper).toList(); + return new PageResult<>(mappedItems, page, size, totalElements, totalPages); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/Quantity.java new file mode 100644 index 000000000..1261200e0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Quantity.java @@ -0,0 +1,20 @@ +package com.loopers.domain; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record Quantity(int value) { + + public Quantity { + if (value < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + } + + public Quantity add(int amount) { + if (amount < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "추가 수량은 1 이상이어야 합니다."); + } + return new Quantity(this.value + amount); + } +} 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..653ec42d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,38 @@ +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; + +@Entity +@Table(name = "brands") +public class Brand extends BaseEntity { + + @Column(name = "name", nullable = false) + private String name; + + protected Brand() {} + + public Brand(String name) { + validateName(name); + this.name = name; + } + + public void rename(String name) { + validateName(name); + this.name = name; + } + + public String getName() { + return name; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java new file mode 100644 index 000000000..e160c47a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java @@ -0,0 +1,47 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +public class BrandDomainService { + + private final BrandRepository brandRepository; + + public Brand register(String name) { + return brandRepository.save(new Brand(name)); + } + + public Brand getById(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + public Map getByIds(Set ids) { + List brands = brandRepository.findAllByIds(ids); + return brands.stream().collect(Collectors.toMap(Brand::getId, brand -> brand)); + } + + public PageResult getAll(int page, int size) { + return brandRepository.findAll(page, size); + } + + public Brand update(Long id, String name) { + Brand brand = getById(id); + brand.rename(name); + return brandRepository.save(brand); + } + + public void delete(Long id) { + Brand brand = getById(id); + brand.delete(); + brandRepository.save(brand); + } +} 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..3db171a40 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.PageResult; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); + + List findAllByIds(Set ids); + + PageResult findAll(int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/Cart.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/Cart.java new file mode 100644 index 000000000..9c8c969c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/Cart.java @@ -0,0 +1,97 @@ +package com.loopers.domain.cart; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +@Getter +@Entity +@Table(name = "carts") +public class Cart { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, unique = true) + private Long userId; + + @OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true) + private List items = new ArrayList<>(); + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected Cart() {} + + public Cart(Long userId) { + Objects.requireNonNull(userId, "유저 ID는 필수입니다."); + this.userId = userId; + } + + public void addItem(Long productId, int quantity) { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + + for (CartItem item : items) { + if (item.getProductId().equals(productId)) { + item.addQuantity(quantity); + return; + } + } + + items.add(new CartItem(this, productId, quantity)); + } + + public void removeItem(Long cartItemId) { + Objects.requireNonNull(cartItemId, "장바구니 항목 ID는 필수입니다."); + + boolean removed = items.removeIf(item -> cartItemId.equals(item.getId())); + if (!removed) { + throw new CoreException(ErrorType.NOT_FOUND, "장바구니 항목을 찾을 수 없습니다."); + } + } + + public void updateItemQuantity(Long cartItemId, int quantity) { + Objects.requireNonNull(cartItemId, "장바구니 항목 ID는 필수입니다."); + + CartItem cartItem = items.stream() + .filter(item -> cartItemId.equals(item.getId())) + .findFirst() + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "장바구니 항목을 찾을 수 없습니다.")); + + cartItem.changeQuantity(quantity); + } + + public void clear() { + items.clear(); + } + + public void removeUnavailableItems(Set availableProductIds) { + items.removeIf(item -> !availableProductIds.contains(item.getProductId())); + } + + public List getItems() { + return Collections.unmodifiableList(items); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java new file mode 100644 index 000000000..8e007f738 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java @@ -0,0 +1,58 @@ +package com.loopers.domain.cart; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; + +import java.util.Set; + +@RequiredArgsConstructor +public class CartDomainService { + + private final CartRepository cartRepository; + + public void addToCart(Long userId, Long productId, int quantity) { + Cart cart = getOrCreateCart(userId); + cart.addItem(productId, quantity); + cartRepository.save(cart); + } + + public void updateItemQuantity(Long userId, Long cartItemId, int quantity) { + Cart cart = getCartByUserId(userId); + cart.updateItemQuantity(cartItemId, quantity); + cartRepository.save(cart); + } + + public void removeItem(Long userId, Long cartItemId) { + Cart cart = getCartByUserId(userId); + cart.removeItem(cartItemId); + cartRepository.save(cart); + } + + public Cart getCart(Long userId) { + return cartRepository.findByUserId(userId) + .orElseGet(() -> new Cart(userId)); + } + + public void clearCart(Long userId) { + cartRepository.findByUserId(userId).ifPresent(cart -> { + cart.clear(); + cartRepository.save(cart); + }); + } + + public void removeUnavailableItems(Cart cart, Set availableProductIds) { + cart.removeUnavailableItems(availableProductIds); + cartRepository.save(cart); + } + + private Cart getOrCreateCart(Long userId) { + return cartRepository.findByUserId(userId) + .orElseGet(() -> new Cart(userId)); + } + + private Cart getCartByUserId(Long userId) { + return cartRepository.findByUserId(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "장바구니를 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java new file mode 100644 index 000000000..6931059a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java @@ -0,0 +1,67 @@ +package com.loopers.domain.cart; + +import com.loopers.domain.Quantity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; +import java.util.Objects; + +@Getter +@Entity +@Table(name = "cart_items") +public class CartItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cart_id", nullable = false) + private Cart cart; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "quantity", nullable = false) + private int quantity; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected CartItem() {} + + CartItem(Cart cart, Long productId, int quantity) { + Objects.requireNonNull(cart, "장바구니는 필수입니다."); + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + this.cart = cart; + this.productId = productId; + this.quantity = new Quantity(quantity).value(); + } + + public Quantity getQuantity() { + return new Quantity(quantity); + } + + public void addQuantity(int amount) { + this.quantity = getQuantity().add(amount).value(); + } + + public void changeQuantity(int quantity) { + this.quantity = new Quantity(quantity).value(); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartRepository.java new file mode 100644 index 000000000..15aca4b1b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.cart; + +import java.util.Optional; + +public interface CartRepository { + + Cart save(Cart cart); + + Optional findByUserId(Long userId); +} 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..24af90ec3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,54 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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() {} + + public Like(Long userId, Long productId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "유저 ID는 필수입니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + this.userId = userId; + this.productId = productId; + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java new file mode 100644 index 000000000..af20f09f5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java @@ -0,0 +1,30 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RequiredArgsConstructor +public class LikeDomainService { + + private final LikeRepository likeRepository; + + public void like(Long userId, Long productId) { + if (likeRepository.existsByUserIdAndProductId(userId, productId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다."); + } + likeRepository.save(new Like(userId, productId)); + } + + public void unlike(Long userId, Long productId) { + Like like = likeRepository.findByUserIdAndProductId(userId, productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "좋아요를 찾을 수 없습니다.")); + likeRepository.delete(like); + } + + public List getMyLikes(Long userId) { + return likeRepository.findAllByUserId(userId); + } +} 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..cce1c8860 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Optional; + +public interface LikeRepository { + + Like save(Like like); + + void delete(Like like); + + boolean existsByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findAllByUserId(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..08b9b0576 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,74 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "total_price", nullable = false) + private int totalPrice; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List items = new ArrayList<>(); + + protected Order() {} + + public Order(Long userId, Money totalPrice) { + validate(userId, totalPrice); + this.userId = userId; + this.totalPrice = totalPrice.amount(); + this.status = OrderStatus.ORDERED; + } + + public void addItems(List commands) { + for (OrderItemCommand cmd : commands) { + this.items.add(new OrderItem( + this, cmd.productId(), cmd.productName(), + cmd.productPrice(), cmd.brandName(), cmd.quantity() + )); + } + } + + public void cancel() { + if (this.status != OrderStatus.ORDERED) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 취소가 불가능한 상태입니다."); + } + this.status = OrderStatus.CANCELLED; + } + + public Long getUserId() { return userId; } + public Money getTotalPrice() { return new Money(totalPrice); } + public OrderStatus getStatus() { return status; } + public List getItems() { return Collections.unmodifiableList(items); } + + private void validate(Long userId, Money totalPrice) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "유저 ID는 필수입니다."); + } + if (totalPrice == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "총 금액은 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java new file mode 100644 index 000000000..84492b116 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java @@ -0,0 +1,78 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +public class OrderDomainService { + + private final OrderRepository orderRepository; + + public Order createOrder(Long userId, List itemCommands) { + if (itemCommands == null || itemCommands.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 하나 이상이어야 합니다."); + } + + List productIds = itemCommands.stream().map(OrderItemCommand::productId).toList(); + OrderPolicy.validateNoDuplicateProducts(productIds); + + Money totalPrice = calculateTotalPrice(itemCommands); + + Order order = new Order(userId, totalPrice); + order.addItems(itemCommands); + + return orderRepository.save(order); + } + + private Money calculateTotalPrice(List itemCommands) { + Money total = new Money(0); + for (OrderItemCommand cmd : itemCommands) { + total = total.plus(cmd.productPrice().multiply(cmd.quantity())); + } + return total; + } + + public Order getById(Long id) { + return orderRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + public Order getByIdWithItems(Long id) { + return orderRepository.findByIdWithItems(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + public Order getByIdAndUserId(Long id, Long userId) { + Order order = getById(id); + if (!order.getUserId().equals(userId)) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + return order; + } + + public Order getByIdAndUserIdWithItems(Long id, Long userId) { + Order order = getByIdWithItems(id); + if (!order.getUserId().equals(userId)) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + return order; + } + + public PageResult getMyOrders(Long userId, LocalDate startAt, LocalDate endAt, int page, int size) { + ZonedDateTime start = startAt.atStartOfDay(ZoneId.systemDefault()); + ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.systemDefault()); + return orderRepository.findByUserIdAndCreatedAtBetween(userId, start, end, page, size); + } + + public PageResult getAllOrders(int page, int size) { + return orderRepository.findAll(page, size); + } +} 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..c244a71aa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,73 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.Quantity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "order_items") +public class OrderItem extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private Order order; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "product_price", nullable = false) + private int productPrice; + + @Column(name = "brand_name", nullable = false) + private String brandName; + + @Column(name = "quantity", nullable = false) + private int quantity; + + protected OrderItem() {} + + OrderItem(Order order, Long productId, String productName, Money productPrice, String brandName, int quantity) { + validate(order, productId, productName, productPrice, brandName); + this.order = order; + this.productId = productId; + this.productName = productName; + this.productPrice = productPrice.amount(); + this.brandName = brandName; + this.quantity = new Quantity(quantity).value(); + } + + public Long getProductId() { return productId; } + public String getProductName() { return productName; } + public Money getProductPrice() { return new Money(productPrice); } + public String getBrandName() { return brandName; } + public Quantity getQuantity() { return new Quantity(quantity); } + + private void validate(Order order, Long productId, String productName, Money productPrice, String brandName) { + if (order == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문은 필수입니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 필수입니다."); + } + if (productPrice == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 필수입니다."); + } + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java new file mode 100644 index 000000000..1ea58043a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java @@ -0,0 +1,30 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.Objects; + +public record OrderItemCommand( + Long productId, + String productName, + Money productPrice, + String brandName, + int quantity +) { + + public OrderItemCommand { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 필수입니다."); + } + Objects.requireNonNull(productPrice, "상품 가격은 필수입니다."); + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPolicy.java new file mode 100644 index 000000000..48bdeb3b5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPolicy.java @@ -0,0 +1,22 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +public class OrderPolicy { + + public static void validateNoDuplicateProducts(Collection productIds) { + Set unique = new HashSet<>(); + for (Long id : productIds) { + if (!unique.add(id)) { + throw new CoreException(ErrorType.BAD_REQUEST, "중복된 상품이 포함되어 있습니다."); + } + } + } + + private OrderPolicy() {} +} 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..2209e39af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; + +import java.time.ZonedDateTime; +import java.util.Optional; + +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + Optional findByIdWithItems(Long id); + + PageResult findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, int page, int size); + + PageResult findAll(int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..b2d11834f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + ORDERED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java new file mode 100644 index 000000000..14d6769e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -0,0 +1,32 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record Money(int amount) { + + public Money { + if (amount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다."); + } + } + + public Money plus(Money other) { + return new Money(this.amount + other.amount); + } + + public Money minus(Money other) { + if (this.amount < other.amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "금액은 음수가 될 수 없습니다."); + } + return new Money(this.amount - other.amount); + } + + public Money multiply(int quantity) { + return new Money(this.amount * quantity); + } + + public boolean isGreaterThanOrEqual(Money other) { + return this.amount >= other.amount; + } +} 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..23b236e81 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,84 @@ +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.Table; + +@Entity +@Table(name = "products") +public class Product extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "price", nullable = false) + private int price; + + @Column(name = "stock", nullable = false) + private int stock; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + protected Product() {} + + public Product(Long brandId, String name, Money price, Stock stock) { + validate(brandId, name, price, stock); + this.brandId = brandId; + this.name = name; + this.price = price.amount(); + this.stock = stock.quantity(); + this.likeCount = 0; + } + + public void changeDetails(String name, Money price, Stock stock) { + validate(this.brandId, name, price, stock); + this.name = name; + this.price = price.amount(); + this.stock = stock.quantity(); + } + + public void deductStock(int quantity) { + Stock currentStock = getStock(); + Stock deducted = currentStock.deduct(quantity); + this.stock = deducted.quantity(); + } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 0 미만이 될 수 없습니다."); + } + this.likeCount--; + } + + public Long getBrandId() { return brandId; } + public String getName() { return name; } + public Money getPrice() { return new Money(price); } + public Stock getStock() { return new Stock(stock); } + public int getLikeCount() { return likeCount; } + + private void validate(Long brandId, String name, Money price, Stock stock) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 필수입니다."); + } + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 재고는 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java new file mode 100644 index 000000000..18e5483fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java @@ -0,0 +1,74 @@ +package com.loopers.domain.product; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +public class ProductDomainService { + + private final ProductRepository productRepository; + + public Product register(Long brandId, String name, int price, int stock) { + return productRepository.save(new Product(brandId, name, new Money(price), new Stock(stock))); + } + + public Product getById(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + public Product getByIdWithLock(Long id) { + return productRepository.findByIdWithLock(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + public Map getByIds(Set ids) { + List products = productRepository.findAllByIds(ids); + return products.stream().collect(Collectors.toMap(Product::getId, product -> product)); + } + + public PageResult getAll(Long brandId, ProductSortType sort, int page, int size) { + return productRepository.findAll(brandId, sort, page, size); + } + + public Product update(Long id, String name, int price, int stock) { + Product product = getById(id); + product.changeDetails(name, new Money(price), new Stock(stock)); + return productRepository.save(product); + } + + public void delete(Long id) { + Product product = getById(id); + product.delete(); + productRepository.save(product); + } + + public void deleteAllByBrandId(Long brandId) { + productRepository.softDeleteAllByBrandId(brandId); + } + + public Product deductStockWithLock(Long productId, int quantity) { + Product product = getByIdWithLock(productId); + product.deductStock(quantity); + return productRepository.save(product); + } + + public void incrementLikeCount(Long productId) { + Product product = getByIdWithLock(productId); + product.incrementLikeCount(); + productRepository.save(product); + } + + public void decrementLikeCount(Long productId) { + Product product = getByIdWithLock(productId); + product.decrementLikeCount(); + productRepository.save(product); + } +} 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..e68f005ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.product; + +import com.loopers.domain.PageResult; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); + + Optional findByIdWithLock(Long id); + + List findAllByIds(Collection ids); + + PageResult findAll(Long brandId, ProductSortType sort, int page, int size); + + void softDeleteAllByBrandId(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..dcf506cad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,21 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public enum ProductSortType { + LATEST, + PRICE_ASC, + LIKES_DESC; + + public static ProductSortType from(String value) { + if (value == null || value.isBlank()) { + return LATEST; + } + try { + return valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 정렬 기준입니다: " + value); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java new file mode 100644 index 000000000..81f22fb9d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java @@ -0,0 +1,20 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record Stock(int quantity) { + + public Stock { + if (quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + } + + public Stock deduct(int amount) { + if (this.quantity < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + return new Stock(this.quantity - amount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java new file mode 100644 index 000000000..38666d965 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java @@ -0,0 +1,48 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@Getter +public class Email { + + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + + @Column(name = "email", nullable = false) + private String value; + + protected Email() {} + + public Email(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); + } + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email email)) return false; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java new file mode 100644 index 000000000..0e9a40076 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java @@ -0,0 +1,48 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@Getter +public class LoginId { + + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + + @Column(name = "login_id", nullable = false, unique = true) + private String value; + + protected LoginId() {} + + public LoginId(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); + } + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 허용됩니다."); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java new file mode 100644 index 000000000..c42456f20 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java @@ -0,0 +1,40 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; + +@Embeddable +public class Password { + + @Column(name = "password", nullable = false) + private String encryptedValue; + + protected Password() {} + + public Password(String encryptedValue) { + if (encryptedValue == null || encryptedValue.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "암호화된 비밀번호는 비어있을 수 없습니다."); + } + this.encryptedValue = encryptedValue; + } + + public String getEncryptedValue() { + return encryptedValue; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Password password)) return false; + return Objects.equals(encryptedValue, password.encryptedValue); + } + + @Override + public int hashCode() { + return Objects.hashCode(encryptedValue); + } +} 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 165fdc0aa..f4a071bc3 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 @@ -1,91 +1,59 @@ package com.loopers.domain.user; import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + import java.time.LocalDate; -import java.util.regex.Pattern; @Entity @Table(name = "users") public class User extends BaseEntity { - private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); - private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); - @Column(name = "login_id", nullable = false, unique = true) - private String loginId; + @Embedded + private LoginId loginId; - @Column(name = "password", nullable = false) - private String password; + @Embedded + private Password password; - @Column(name = "name", nullable = false) - private String name; + @Embedded + private UserName name; @Column(name = "birth_date", nullable = false) private LocalDate birthDate; - @Column(name = "email", nullable = false) - private String email; + @Embedded + private Email email; protected User() {} - public User(String loginId, String password, String name, LocalDate birthDate, String email) { - validateLoginId(loginId); - validateName(name); - validateBirthDate(birthDate); - validateEmail(email); - - this.loginId = loginId; - this.password = password; - this.name = name; - this.birthDate = birthDate; - this.email = email; - } - - private void validateLoginId(String loginId) { - if (loginId == null || loginId.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); - } - if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { - 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 validateBirthDate(LocalDate birthDate) { + public User(String loginId, String encryptedPassword, String name, LocalDate birthDate, String email) { if (birthDate == null) { throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); } - } - private void validateEmail(String email) { - if (email == null || email.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); - } - if (!EMAIL_PATTERN.matcher(email).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); - } + this.loginId = new LoginId(loginId); + this.password = new Password(encryptedPassword); + this.name = new UserName(name); + this.birthDate = birthDate; + this.email = new Email(email); } public String getLoginId() { - return loginId; + return loginId.getValue(); } public String getPassword() { - return password; + return password.getEncryptedValue(); } public String getName() { - return name; + return name.getValue(); } public LocalDate getBirthDate() { @@ -93,17 +61,14 @@ public LocalDate getBirthDate() { } public String getEmail() { - return email; + return email.getValue(); } public void changePassword(String newEncryptedPassword) { - this.password = newEncryptedPassword; + this.password = new Password(newEncryptedPassword); } public String getMaskedName() { - if (name.length() <= 1) { - return "*"; - } - return name.substring(0, name.length() - 1) + "*"; + return name.mask(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java similarity index 80% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java index 30d3ee5c7..b9ebaf7fd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java @@ -7,11 +7,16 @@ import java.time.LocalDate; @RequiredArgsConstructor -public class UserService { +public class UserDomainService { private final UserRepository userRepository; private final PasswordEncryptor passwordEncryptor; + public User getById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + } + public User signup(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { if (userRepository.existsByLoginId(loginId)) { throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다."); @@ -25,7 +30,10 @@ public User signup(String loginId, String rawPassword, String name, LocalDate bi return userRepository.save(user); } - public void changePassword(User user, String currentRawPassword, String newRawPassword) { + public void changePassword(Long userId, String currentRawPassword, String newRawPassword) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + if (!passwordEncryptor.matches(currentRawPassword, user.getPassword())) { throw new CoreException(ErrorType.BAD_REQUEST, "기존 비밀번호가 올바르지 않습니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java new file mode 100644 index 000000000..f9d243c7f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java @@ -0,0 +1,45 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; + +import java.util.Objects; + +@Embeddable +@Getter +public class UserName { + + @Column(name = "name", nullable = false) + private String value; + + protected UserName() {} + + public UserName(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + this.value = value; + } + + public String mask() { + if (value.length() <= 1) { + return "*"; + } + return value.substring(0, value.length() - 1) + "*"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof UserName userName)) return false; + return Objects.equals(value, userName.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} 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 2dcb54ae8..7656f46f2 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,6 +5,8 @@ public interface UserRepository { User save(User user); + Optional findById(Long id); + boolean existsByLoginId(String loginId); Optional findByLoginId(String loginId); 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..c7c0bf360 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,19 @@ +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; +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + List findAllByIdInAndDeletedAtIsNull(Collection ids); + + Page findAllByDeletedAtIsNull(Pageable pageable); +} 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..ff5f43432 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,47 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.PageResult; +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.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findAllByIds(Set ids) { + return brandJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + + @Override + public PageResult findAll(int page, int size) { + Page result = brandJpaRepository.findAllByDeletedAtIsNull(PageRequest.of(page, size)); + return new PageResult<>( + result.getContent(), + result.getNumber(), + result.getSize(), + result.getTotalElements(), + result.getTotalPages() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartJpaRepository.java new file mode 100644 index 000000000..87e69b81c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.cart; + +import com.loopers.domain.cart.Cart; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface CartJpaRepository extends JpaRepository { + + @Query("SELECT c FROM Cart c LEFT JOIN FETCH c.items WHERE c.userId = :userId") + Optional findByUserId(@Param("userId") Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartRepositoryImpl.java new file mode 100644 index 000000000..2ec9a0c89 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.cart; + +import com.loopers.domain.cart.Cart; +import com.loopers.domain.cart.CartRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class CartRepositoryImpl implements CartRepository { + + private final CartJpaRepository cartJpaRepository; + + @Override + public Cart save(Cart cart) { + return cartJpaRepository.save(cart); + } + + @Override + public Optional findByUserId(Long userId) { + return cartJpaRepository.findByUserId(userId); + } +} 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..d7c4c4892 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findAllByUserId(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..f6a7c109f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + @Override + public boolean existsByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.existsByUserIdAndProductId(userId, productId); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public List findAllByUserId(Long userId) { + return likeJpaRepository.findAllByUserId(userId); + } +} 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..91ca724c9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.ZonedDateTime; +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + @Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id AND o.deletedAt IS NULL") + Optional findByIdWithItems(@Param("id") Long id); + + Page findByUserIdAndCreatedAtBetweenAndDeletedAtIsNull( + Long userId, ZonedDateTime startAt, ZonedDateTime endAt, Pageable pageable + ); + + Page findAllByDeletedAtIsNull(Pageable pageable); +} 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..590faa7ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.PageResult; +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.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByIdWithItems(Long id) { + return orderJpaRepository.findByIdWithItems(id); + } + + @Override + public PageResult findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = orderJpaRepository.findByUserIdAndCreatedAtBetweenAndDeletedAtIsNull(userId, startAt, endAt, pageRequest); + return new PageResult<>( + result.getContent(), result.getNumber(), result.getSize(), + result.getTotalElements(), result.getTotalPages() + ); + } + + @Override + public PageResult findAll(int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = orderJpaRepository.findAllByDeletedAtIsNull(pageRequest); + return new PageResult<>( + result.getContent(), result.getNumber(), result.getSize(), + result.getTotalElements(), result.getTotalPages() + ); + } +} 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..0d7043282 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id = :id AND p.deletedAt IS NULL") + Optional findByIdWithLock(@Param("id") Long id); + + List findAllByIdInAndDeletedAtIsNull(Collection ids); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE Product p SET p.deletedAt = CURRENT_TIMESTAMP, p.updatedAt = CURRENT_TIMESTAMP WHERE p.brandId = :brandId AND p.deletedAt IS NULL") + void softDeleteAllByBrandId(@Param("brandId") Long brandId); + +} 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..4572c37c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,75 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByIdWithLock(Long id) { + return productJpaRepository.findByIdWithLock(id); + } + + @Override + public List findAllByIds(Collection ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + + @Override + public PageResult findAll(Long brandId, ProductSortType sort, int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, toSort(sort)); + + Page result; + if (brandId != null) { + result = productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageRequest); + } else { + result = productJpaRepository.findAllByDeletedAtIsNull(pageRequest); + } + + return new PageResult<>( + result.getContent(), + result.getNumber(), + result.getSize(), + result.getTotalElements(), + result.getTotalPages() + ); + } + + @Override + public void softDeleteAllByBrandId(Long brandId) { + productJpaRepository.softDeleteAllByBrandId(brandId); + } + + private Sort toSort(ProductSortType sortType) { + return switch (sortType) { + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price"); + case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount"); + case LATEST -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index 5f9b5b949..98a663a49 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -2,9 +2,19 @@ import com.loopers.domain.user.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; public interface UserJpaRepository extends JpaRepository { - boolean existsByLoginId(String loginId); - java.util.Optional findByLoginId(String loginId); + @Query("SELECT u FROM User u WHERE u.id = :id AND u.deletedAt IS NULL") + Optional findByIdAndDeletedAtIsNull(@Param("id") Long id); + + @Query("SELECT COUNT(u) > 0 FROM User u WHERE u.loginId.value = :loginId AND u.deletedAt IS NULL") + boolean existsByLoginId(@Param("loginId") String loginId); + + @Query("SELECT u FROM User u WHERE u.loginId.value = :loginId AND u.deletedAt IS NULL") + Optional findByLoginId(@Param("loginId") String loginId); } 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 36d78918b..1a1e76664 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 @@ -17,6 +17,11 @@ public User save(User user) { return userJpaRepository.save(user); } + @Override + public Optional findById(Long id) { + return userJpaRepository.findByIdAndDeletedAtIsNull(id); + } + @Override public boolean existsByLoginId(String loginId) { return userJpaRepository.existsByLoginId(loginId); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 041716398..f62fe83b9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -7,8 +7,10 @@ import com.loopers.support.error.ErrorType; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -39,6 +41,15 @@ public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatc return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(fieldError -> fieldError.getDefaultMessage()) + .findFirst() + .orElse(ErrorType.BAD_REQUEST.getMessage()); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(MissingServletRequestParameterException e) { String name = e.getParameterName(); @@ -47,6 +58,11 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(IllegalArgumentException e) { + return failureResponse(ErrorType.BAD_REQUEST, e.getMessage()); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; @@ -127,7 +143,7 @@ private String extractMissingParameter(String message) { } private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) { - return ResponseEntity.status(errorType.getStatus()) + return ResponseEntity.status(HttpStatus.valueOf(errorType.getStatusCode())) .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java index 33b77b529..811da17c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java @@ -15,7 +15,7 @@ public static Metadata fail(String errorCode, String errorMessage) { } } - public static ApiResponse success() { + public static ApiResponse success() { return new ApiResponse<>(Metadata.success(), null); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminAuthInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminAuthInterceptor.java new file mode 100644 index 000000000..aba812c73 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminAuthInterceptor.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AdminAuthInterceptor implements HandlerInterceptor { + + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP = "loopers.admin"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String ldap = request.getHeader(HEADER_LDAP); + if (!ADMIN_LDAP.equals(ldap)) { + throw new CoreException(ErrorType.UNAUTHORIZED, "어드민 인증에 실패했습니다."); + } + return true; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java index fc462ba91..2140ea5a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.auth; +import com.loopers.application.user.UserApplicationService; import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -19,16 +19,16 @@ public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; - private final UserService userService; + private final UserApplicationService userApplicationService; @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(AuthUser.class) - && parameter.getParameterType().equals(User.class); + && parameter.getParameterType().equals(AuthenticatedUser.class); } @Override - public User resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + public AuthenticatedUser resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { String loginId = webRequest.getHeader(HEADER_LOGIN_ID); String password = webRequest.getHeader(HEADER_LOGIN_PW); @@ -37,6 +37,7 @@ public User resolveArgument(MethodParameter parameter, ModelAndViewContainer mav throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 누락되었습니다."); } - return userService.authenticate(loginId, password); + User user = userApplicationService.authenticate(loginId, password); + return new AuthenticatedUser(user.getId(), user.getLoginId()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java new file mode 100644 index 000000000..716c1e9ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.api.auth; + +public record AuthenticatedUser(Long userId, String loginId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/WebMvcConfig.java index 1b05ec7b3..4e311ba47 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/WebMvcConfig.java @@ -3,6 +3,7 @@ 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; @@ -12,9 +13,16 @@ public class WebMvcConfig implements WebMvcConfigurer { private final AuthUserArgumentResolver authUserArgumentResolver; + private final AdminAuthInterceptor adminAuthInterceptor; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(authUserArgumentResolver); } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminAuthInterceptor) + .addPathPatterns("/api-admin/**"); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1ApiSpec.java new file mode 100644 index 000000000..f7255c2f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1ApiSpec.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Brand V1 API", description = "어드민 브랜드 관리 API 입니다.") +public interface AdminBrandV1ApiSpec { + + @Operation(summary = "브랜드 등록", description = "새로운 브랜드를 등록합니다.") + ApiResponse create(AdminBrandV1Dto.CreateRequest request); + + @Operation(summary = "브랜드 목록 조회", description = "브랜드 목록을 페이지 단위로 조회합니다.") + ApiResponse getAll(int page, int size); + + @Operation(summary = "브랜드 상세 조회", description = "특정 브랜드의 상세 정보를 조회합니다.") + ApiResponse getById(Long brandId); + + @Operation(summary = "브랜드 수정", description = "브랜드 정보를 수정합니다.") + ApiResponse update(Long brandId, AdminBrandV1Dto.UpdateRequest request); + + @Operation(summary = "브랜드 삭제", description = "브랜드를 삭제합니다. 해당 브랜드의 모든 상품도 함께 삭제됩니다.") + ApiResponse delete(Long brandId); +} 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..aa307ac73 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java @@ -0,0 +1,66 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class AdminBrandV1Controller implements AdminBrandV1ApiSpec { + + private final BrandApplicationService brandApplicationService; + + @PostMapping + @Override + public ApiResponse create(@Valid @RequestBody AdminBrandV1Dto.CreateRequest request) { + Brand brand = brandApplicationService.register(request.name()); + return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(brand)); + } + + @GetMapping + @Override + public ApiResponse getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageResult result = brandApplicationService.getAll(page, size); + return ApiResponse.success(AdminBrandV1Dto.BrandPageResponse.from(result)); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse getById(@PathVariable Long brandId) { + Brand brand = brandApplicationService.getById(brandId); + return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(brand)); + } + + @PutMapping("/{brandId}") + @Override + public ApiResponse update( + @PathVariable Long brandId, + @Valid @RequestBody AdminBrandV1Dto.UpdateRequest request + ) { + Brand brand = brandApplicationService.update(brandId, request.name()); + return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(brand)); + } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse delete(@PathVariable Long brandId) { + brandApplicationService.delete(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Dto.java new file mode 100644 index 000000000..f35bd29cc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Dto.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import jakarta.validation.constraints.NotBlank; + +import java.time.ZonedDateTime; +import java.util.List; + +public class AdminBrandV1Dto { + + public record CreateRequest( + @NotBlank(message = "브랜드 이름은 비어있을 수 없습니다.") + String name + ) {} + + public record UpdateRequest( + @NotBlank(message = "브랜드 이름은 비어있을 수 없습니다.") + String name + ) {} + + public record BrandResponse(Long id, String name, ZonedDateTime createdAt, ZonedDateTime updatedAt) { + public static BrandResponse from(Brand brand) { + return new BrandResponse(brand.getId(), brand.getName(), brand.getCreatedAt(), brand.getUpdatedAt()); + } + } + + public record BrandPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static BrandPageResponse from(PageResult result) { + List content = result.items().stream() + .map(BrandResponse::from) + .toList(); + return new BrandPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..f977e7311 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,12 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand V1 API", description = "브랜드 API 입니다.") +public interface BrandV1ApiSpec { + + @Operation(summary = "브랜드 정보 조회", description = "특정 브랜드의 정보를 조회합니다.") + ApiResponse getById(Long brandId); +} 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..09a665058 --- /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.BrandApplicationService; +import com.loopers.domain.brand.Brand; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandApplicationService brandApplicationService; + + @GetMapping("/{brandId}") + @Override + public ApiResponse getById(@PathVariable Long brandId) { + Brand brand = brandApplicationService.getById(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(brand)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..a2379f523 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,12 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.domain.brand.Brand; + +public class BrandV1Dto { + + public record BrandResponse(Long id, String name) { + public static BrandResponse from(Brand brand) { + return new BrandResponse(brand.getId(), brand.getName()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java new file mode 100644 index 000000000..f3c2af3fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java @@ -0,0 +1,23 @@ +package com.loopers.interfaces.api.cart; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Cart V1 API", description = "장바구니 API 입니다.") +public interface CartV1ApiSpec { + + @Operation(summary = "장바구니 담기", description = "상품을 장바구니에 담습니다. 이미 담긴 상품이면 수량이 합산됩니다.") + ApiResponse addToCart(@Parameter(hidden = true) AuthenticatedUser authUser, CartV1Dto.AddRequest request); + + @Operation(summary = "장바구니 조회", description = "내 장바구니를 조회합니다.") + ApiResponse getMyCart(@Parameter(hidden = true) AuthenticatedUser authUser); + + @Operation(summary = "수량 변경", description = "장바구니 항목의 수량을 변경합니다.") + ApiResponse updateQuantity(@Parameter(hidden = true) AuthenticatedUser authUser, Long cartItemId, CartV1Dto.UpdateQuantityRequest request); + + @Operation(summary = "항목 삭제", description = "장바구니에서 항목을 삭제합니다.") + ApiResponse removeItem(@Parameter(hidden = true) AuthenticatedUser authUser, Long cartItemId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java new file mode 100644 index 000000000..c98fa34ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java @@ -0,0 +1,64 @@ +package com.loopers.interfaces.api.cart; + +import com.loopers.application.cart.CartApplicationService; +import com.loopers.application.cart.CartItemDetail; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthUser; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/cart") +public class CartV1Controller implements CartV1ApiSpec { + + private final CartApplicationService cartApplicationService; + + @PostMapping("/items") + @Override + public ApiResponse addToCart(@AuthUser AuthenticatedUser authUser, @Valid @RequestBody CartV1Dto.AddRequest request) { + cartApplicationService.addToCart(authUser.userId(), request.productId(), request.quantity()); + return ApiResponse.success(); + } + + @GetMapping + @Override + public ApiResponse getMyCart(@AuthUser AuthenticatedUser authUser) { + List details = cartApplicationService.getMyCartWithDetails(authUser.userId()); + + List itemResponses = details.stream() + .map(detail -> CartV1Dto.CartItemResponse.from(detail.cartItem(), detail.product(), detail.brand())) + .toList(); + + return ApiResponse.success(CartV1Dto.CartResponse.from(itemResponses)); + } + + @PutMapping("/items/{cartItemId}") + @Override + public ApiResponse updateQuantity( + @AuthUser AuthenticatedUser authUser, + @PathVariable Long cartItemId, + @Valid @RequestBody CartV1Dto.UpdateQuantityRequest request + ) { + cartApplicationService.updateQuantity(cartItemId, authUser.userId(), request.quantity()); + return ApiResponse.success(); + } + + @DeleteMapping("/items/{cartItemId}") + @Override + public ApiResponse removeItem(@AuthUser AuthenticatedUser authUser, @PathVariable Long cartItemId) { + cartApplicationService.removeItem(cartItemId, authUser.userId()); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java new file mode 100644 index 000000000..70585834c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.cart; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.product.Product; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class CartV1Dto { + + public record AddRequest( + @NotNull(message = "상품 ID는 필수입니다.") Long productId, + @Min(value = 1, message = "수량은 1 이상이어야 합니다.") int quantity + ) {} + + public record UpdateQuantityRequest( + @Min(value = 1, message = "수량은 1 이상이어야 합니다.") int quantity + ) {} + + public record CartItemResponse( + Long cartItemId, + Long productId, + String productName, + String brandName, + int price, + int quantity + ) { + public static CartItemResponse from(CartItem cartItem, Product product, Brand brand) { + return new CartItemResponse( + cartItem.getId(), product.getId(), product.getName(), + brand.getName(), product.getPrice().amount(), cartItem.getQuantity().value() + ); + } + } + + public record CartResponse(List items) { + public static CartResponse from(List items) { + return new CartResponse(items); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..1be8a3c36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Like V1 API", description = "좋아요 API 입니다.") +public interface LikeV1ApiSpec { + + @Operation(summary = "좋아요 등록", description = "상품에 좋아요를 등록합니다.") + ApiResponse like(@Parameter(hidden = true) AuthenticatedUser authUser, Long productId); + + @Operation(summary = "좋아요 취소", description = "상품의 좋아요를 취소합니다.") + ApiResponse unlike(@Parameter(hidden = true) AuthenticatedUser authUser, Long productId); + + @Operation(summary = "내 좋아요 목록 조회", description = "내가 좋아요한 상품 목록을 조회합니다.") + ApiResponse getMyLikes(@Parameter(hidden = true) AuthenticatedUser authUser); +} 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..9cc0dabab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeApplicationService; +import com.loopers.application.like.LikedProductDetail; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthUser; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import lombok.RequiredArgsConstructor; +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.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeApplicationService likeApplicationService; + + @PostMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse like(@AuthUser AuthenticatedUser authUser, @PathVariable Long productId) { + likeApplicationService.like(authUser.userId(), productId); + return ApiResponse.success(); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse unlike(@AuthUser AuthenticatedUser authUser, @PathVariable Long productId) { + likeApplicationService.unlike(authUser.userId(), productId); + return ApiResponse.success(); + } + + @GetMapping("/api/v1/likes") + @Override + public ApiResponse getMyLikes(@AuthUser AuthenticatedUser authUser) { + List details = likeApplicationService.getMyLikesWithDetails(authUser.userId()); + + List likeResponses = details.stream() + .map(detail -> LikeV1Dto.LikeResponse.from(detail.like(), detail.product(), detail.brand())) + .toList(); + + return ApiResponse.success(LikeV1Dto.LikeListResponse.from(likeResponses)); + } +} 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..0291622bf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,34 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; + +import java.time.ZonedDateTime; +import java.util.List; + +public class LikeV1Dto { + + public record LikeResponse( + Long likeId, + Long productId, + String productName, + String brandName, + int price, + int likeCount, + ZonedDateTime likedAt + ) { + public static LikeResponse from(Like like, Product product, Brand brand) { + return new LikeResponse( + like.getId(), product.getId(), product.getName(), + brand.getName(), product.getPrice().amount(), product.getLikeCount(), like.getCreatedAt() + ); + } + } + + public record LikeListResponse(List likes) { + public static LikeListResponse from(List likes) { + return new LikeListResponse(likes); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1ApiSpec.java new file mode 100644 index 000000000..453fa2f5b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1ApiSpec.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Order V1 API", description = "어드민 주문 API 입니다.") +public interface AdminOrderV1ApiSpec { + + @Operation(summary = "주문 목록 조회", description = "전체 주문 목록을 페이지 단위로 조회합니다.") + ApiResponse getAllOrders(int page, int size); + + @Operation(summary = "주문 상세 조회", description = "주문 상세 내역을 조회합니다.") + ApiResponse getOrderDetail(Long orderId); +} 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..e25a78134 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderApplicationService; +import com.loopers.domain.PageResult; +import com.loopers.domain.order.Order; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class AdminOrderV1Controller implements AdminOrderV1ApiSpec { + + private final OrderApplicationService orderApplicationService; + + @GetMapping + @Override + public ApiResponse getAllOrders( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageResult result = orderApplicationService.getAllOrders(page, size); + return ApiResponse.success(AdminOrderV1Dto.OrderPageResponse.from(result)); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getOrderDetail(@PathVariable Long orderId) { + Order order = orderApplicationService.getOrder(orderId); + return ApiResponse.success(AdminOrderV1Dto.OrderDetailResponse.from(order)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Dto.java new file mode 100644 index 000000000..b0d0a6299 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Dto.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; + +import java.time.ZonedDateTime; +import java.util.List; + +public class AdminOrderV1Dto { + + public record OrderResponse( + Long orderId, + Long userId, + int totalPrice, + String status, + ZonedDateTime createdAt + ) { + public static OrderResponse from(Order order) { + return new OrderResponse(order.getId(), order.getUserId(), order.getTotalPrice().amount(), order.getStatus().name(), order.getCreatedAt()); + } + } + + public record OrderDetailResponse( + Long orderId, + Long userId, + int totalPrice, + String status, + ZonedDateTime createdAt, + List items + ) { + public static OrderDetailResponse from(Order order) { + List items = order.getItems().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderDetailResponse( + order.getId(), order.getUserId(), order.getTotalPrice().amount(), order.getStatus().name(), order.getCreatedAt(), items + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + String brandName, + int quantity + ) { + public static OrderItemResponse from(OrderItem item) { + return new OrderItemResponse( + item.getProductId(), item.getProductName(), item.getProductPrice().amount(), + item.getBrandName(), item.getQuantity().value() + ); + } + } + + public record OrderPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static OrderPageResponse from(PageResult result) { + List content = result.items().stream() + .map(OrderResponse::from) + .toList(); + return new OrderPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..dfba87402 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.time.LocalDate; + +@Tag(name = "Order V1 API", description = "주문 API 입니다.") +public interface OrderV1ApiSpec { + + @Operation(summary = "주문 요청", description = "상품을 직접 지정하여 주문합니다.") + ApiResponse createOrder(@Parameter(hidden = true) AuthenticatedUser authUser, OrderV1Dto.CreateOrderRequest request); + + @Operation(summary = "장바구니 주문", description = "장바구니의 모든 항목으로 주문합니다.") + ApiResponse createOrderFromCart(@Parameter(hidden = true) AuthenticatedUser authUser); + + @Operation(summary = "내 주문 목록 조회", description = "기간별 주문 목록을 조회합니다.") + ApiResponse getMyOrders(@Parameter(hidden = true) AuthenticatedUser authUser, LocalDate startAt, LocalDate endAt, int page, int size); + + @Operation(summary = "주문 상세 조회", description = "주문 상세 내역을 조회합니다.") + ApiResponse getMyOrderDetail(@Parameter(hidden = true) AuthenticatedUser authUser, Long orderId); +} 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..8f6ea9fa6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.CreateOrderCommand; +import com.loopers.application.order.OrderApplicationService; +import com.loopers.domain.PageResult; +import com.loopers.domain.order.Order; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthUser; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderApplicationService orderApplicationService; + + @PostMapping + @Override + public ApiResponse createOrder( + @AuthUser AuthenticatedUser authUser, + @Valid @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + CreateOrderCommand command = new CreateOrderCommand( + authUser.userId(), + request.items().stream() + .map(i -> new CreateOrderCommand.LineItem(i.productId(), i.quantity())) + .toList() + ); + Order order = orderApplicationService.createOrder(command); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(order)); + } + + @PostMapping("/cart") + @Override + public ApiResponse createOrderFromCart(@AuthUser AuthenticatedUser authUser) { + Order order = orderApplicationService.createOrderFromCart(authUser.userId()); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(order)); + } + + @GetMapping + @Override + public ApiResponse getMyOrders( + @AuthUser AuthenticatedUser authUser, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageResult result = orderApplicationService.getMyOrders(authUser.userId(), startAt, endAt, page, size); + return ApiResponse.success(OrderV1Dto.OrderPageResponse.from(result)); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getMyOrderDetail( + @AuthUser AuthenticatedUser authUser, + @PathVariable Long orderId + ) { + Order order = orderApplicationService.getMyOrder(authUser.userId(), orderId); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(order)); + } +} 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..b8a998441 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,85 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateOrderRequest( + @NotEmpty(message = "주문 항목은 하나 이상이어야 합니다.") + @Valid + List items + ) {} + + public record OrderItemRequest( + @NotNull(message = "상품 ID는 필수입니다.") + Long productId, + + @Min(value = 1, message = "수량은 1 이상이어야 합니다.") + int quantity + ) {} + + public record OrderResponse( + Long orderId, + int totalPrice, + String status, + ZonedDateTime createdAt + ) { + public static OrderResponse from(Order order) { + return new OrderResponse(order.getId(), order.getTotalPrice().amount(), order.getStatus().name(), order.getCreatedAt()); + } + } + + public record OrderDetailResponse( + Long orderId, + int totalPrice, + String status, + ZonedDateTime createdAt, + List items + ) { + public static OrderDetailResponse from(Order order) { + List items = order.getItems().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderDetailResponse(order.getId(), order.getTotalPrice().amount(), order.getStatus().name(), order.getCreatedAt(), items); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + String brandName, + int quantity + ) { + public static OrderItemResponse from(OrderItem item) { + return new OrderItemResponse( + item.getProductId(), item.getProductName(), item.getProductPrice().amount(), + item.getBrandName(), item.getQuantity().value() + ); + } + } + + public record OrderPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static OrderPageResponse from(PageResult result) { + List content = result.items().stream() + .map(OrderResponse::from) + .toList(); + return new OrderPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1ApiSpec.java new file mode 100644 index 000000000..654998880 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1ApiSpec.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Product V1 API", description = "어드민 상품 관리 API 입니다.") +public interface AdminProductV1ApiSpec { + + @Operation(summary = "상품 등록", description = "새로운 상품을 등록합니다. 상품의 브랜드는 이미 등록된 브랜드여야 합니다.") + ApiResponse create(AdminProductV1Dto.CreateRequest request); + + @Operation(summary = "상품 목록 조회", description = "상품 목록을 페이지 단위로 조회합니다. 브랜드별 필터링이 가능합니다.") + ApiResponse getAll(Long brandId, int page, int size); + + @Operation(summary = "상품 상세 조회", description = "특정 상품의 상세 정보를 조회합니다.") + ApiResponse getById(Long productId); + + @Operation(summary = "상품 수정", description = "상품 정보를 수정합니다. 상품의 브랜드는 수정할 수 없습니다.") + ApiResponse update(Long productId, AdminProductV1Dto.UpdateRequest request); + + @Operation(summary = "상품 삭제", description = "상품을 삭제합니다.") + ApiResponse delete(Long productId); +} 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..f1c70b50c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductApplicationService; +import com.loopers.application.product.ProductPageWithBrands; +import com.loopers.application.product.ProductWithBrand; +import com.loopers.application.product.RegisterProductCommand; +import com.loopers.application.product.UpdateProductCommand; +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class AdminProductV1Controller implements AdminProductV1ApiSpec { + + private final ProductApplicationService productApplicationService; + + @PostMapping + @Override + public ApiResponse create(@Valid @RequestBody AdminProductV1Dto.CreateRequest request) { + RegisterProductCommand command = new RegisterProductCommand( + request.brandId(), request.name(), request.price(), request.stock()); + ProductWithBrand result = productApplicationService.register(command); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(result.product(), result.brand())); + } + + @GetMapping + @Override + public ApiResponse getAll( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + ProductPageWithBrands result = productApplicationService.getAllForAdmin(brandId, ProductSortType.LATEST, page, size); + return ApiResponse.success(AdminProductV1Dto.ProductPageResponse.from(result.result(), result.brandMap())); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getById(@PathVariable Long productId) { + ProductWithBrand result = productApplicationService.getProductWithBrand(productId); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(result.product(), result.brand())); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse update( + @PathVariable Long productId, + @Valid @RequestBody AdminProductV1Dto.UpdateRequest request + ) { + UpdateProductCommand command = new UpdateProductCommand( + productId, request.name(), request.price(), request.stock()); + ProductWithBrand result = productApplicationService.update(command); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(result.product(), result.brand())); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse delete(@PathVariable Long productId) { + productApplicationService.delete(productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java new file mode 100644 index 000000000..ef461107c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java @@ -0,0 +1,79 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; + +public class AdminProductV1Dto { + + public record CreateRequest( + @NotNull(message = "브랜드 ID는 필수입니다.") + Long brandId, + + @NotBlank(message = "상품 이름은 비어있을 수 없습니다.") + String name, + + @NotNull(message = "상품 가격은 필수입니다.") + @Min(value = 0, message = "상품 가격은 0 이상이어야 합니다.") + Integer price, + + @NotNull(message = "상품 재고는 필수입니다.") + @Min(value = 0, message = "상품 재고는 0 이상이어야 합니다.") + Integer stock + ) {} + + public record UpdateRequest( + @NotBlank(message = "상품 이름은 비어있을 수 없습니다.") + String name, + + @NotNull(message = "상품 가격은 필수입니다.") + @Min(value = 0, message = "상품 가격은 0 이상이어야 합니다.") + Integer price, + + @NotNull(message = "상품 재고는 필수입니다.") + @Min(value = 0, message = "상품 재고는 0 이상이어야 합니다.") + Integer stock + ) {} + + public record ProductResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + int likeCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static ProductResponse from(Product product, Brand brand) { + return new ProductResponse( + product.getId(), product.getBrandId(), brand.getName(), product.getName(), + product.getPrice().amount(), product.getStock().quantity(), product.getLikeCount(), + product.getCreatedAt(), product.getUpdatedAt() + ); + } + } + + public record ProductPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductPageResponse from(PageResult result, Map brandMap) { + List content = result.items().stream() + .map(product -> ProductResponse.from(product, brandMap.get(product.getBrandId()))) + .toList(); + return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..0c57667c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Product V1 API", description = "상품 API 입니다.") +public interface ProductV1ApiSpec { + + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 조회합니다. 브랜드별 필터링과 정렬이 가능합니다. " + + "연관 데이터 삭제로 인해 content 수가 totalElements보다 적을 수 있습니다." + ) + ApiResponse getAll(Long brandId, String sort, int page, int size); + + @Operation(summary = "상품 정보 조회", description = "특정 상품의 정보를 조회합니다.") + ApiResponse getById(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..c7dd9539a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductApplicationService; +import com.loopers.application.product.ProductPageWithBrands; +import com.loopers.application.product.ProductWithBrand; +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductApplicationService productApplicationService; + + @GetMapping + @Override + public ApiResponse getAll( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + ProductPageWithBrands result = productApplicationService.getAll(brandId, ProductSortType.from(sort), page, size); + return ApiResponse.success(ProductV1Dto.ProductPageResponse.from(result.result(), result.brandMap())); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getById(@PathVariable Long productId) { + ProductWithBrand result = productApplicationService.getProductWithBrand(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(result.product(), result.brand())); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..428772d85 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +import java.util.List; +import java.util.Map; + +public class ProductV1Dto { + + public record ProductResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int likeCount + ) { + public static ProductResponse from(Product product, Brand brand) { + return new ProductResponse( + product.getId(), product.getBrandId(), brand.getName(), product.getName(), + product.getPrice().amount(), product.getLikeCount() + ); + } + } + + public record ProductPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductPageResponse from(PageResult result, Map brandMap) { + List content = result.items().stream() + .filter(product -> brandMap.containsKey(product.getBrandId())) + .map(product -> ProductResponse.from(product, brandMap.get(product.getBrandId()))) + .toList(); + return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index e0e32d362..28475ce31 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.user; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -18,11 +19,11 @@ public interface UserV1ApiSpec { summary = "내 정보 조회", description = "로그인한 유저의 정보를 조회합니다. 이름은 마지막 글자가 마스킹됩니다." ) - ApiResponse getMe(@Parameter(hidden = true) com.loopers.domain.user.User user); + ApiResponse getMe(@Parameter(hidden = true) AuthenticatedUser authUser); @Operation( summary = "비밀번호 변경", description = "로그인한 유저의 비밀번호를 변경합니다." ) - ApiResponse changePassword(@Parameter(hidden = true) com.loopers.domain.user.User user, UserV1Dto.ChangePasswordRequest request); + ApiResponse changePassword(@Parameter(hidden = true) AuthenticatedUser authUser, UserV1Dto.ChangePasswordRequest request); } 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 23ece8a31..d4802184b 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 @@ -1,10 +1,11 @@ package com.loopers.interfaces.api.user; -import com.loopers.application.user.UserFacade; -import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserApplicationService; import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; +import com.loopers.interfaces.api.auth.AuthenticatedUser; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -18,34 +19,32 @@ @RequestMapping("/api/v1/users") public class UserV1Controller implements UserV1ApiSpec { - private final UserFacade userFacade; + private final UserApplicationService userApplicationService; @PostMapping @Override - public ApiResponse signup(@RequestBody UserV1Dto.SignupRequest request) { - UserInfo info = userFacade.signup( + public ApiResponse signup(@Valid @RequestBody UserV1Dto.SignupRequest request) { + User user = userApplicationService.signup( request.loginId(), request.password(), request.name(), request.birthDate(), request.email() ); - UserV1Dto.SignupResponse response = UserV1Dto.SignupResponse.from(info); - return ApiResponse.success(response); + return ApiResponse.success(UserV1Dto.SignupResponse.from(user)); } @GetMapping("/me") @Override - public ApiResponse getMe(@AuthUser User user) { - UserInfo info = userFacade.getMyInfo(user); - UserV1Dto.MeResponse response = UserV1Dto.MeResponse.from(info); - return ApiResponse.success(response); + public ApiResponse getMe(@AuthUser AuthenticatedUser authUser) { + User user = userApplicationService.getById(authUser.userId()); + return ApiResponse.success(UserV1Dto.MeResponse.from(user)); } @PutMapping("/password") @Override - public ApiResponse changePassword(@AuthUser User user, @RequestBody UserV1Dto.ChangePasswordRequest request) { - userFacade.changePassword(user, request.currentPassword(), request.newPassword()); - return ApiResponse.success(null); + public ApiResponse changePassword(@AuthUser AuthenticatedUser authUser, @Valid @RequestBody UserV1Dto.ChangePasswordRequest request) { + userApplicationService.changePassword(authUser.userId(), request.currentPassword(), request.newPassword()); + return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index de2a1969c..1530cf829 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -1,36 +1,59 @@ package com.loopers.interfaces.api.user; -import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.User; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import java.time.LocalDate; public class UserV1Dto { - public record SignupRequest(String loginId, String password, String name, LocalDate birthDate, String email) { - } + public record SignupRequest( + @NotBlank(message = "로그인 ID는 비어있을 수 없습니다.") + String loginId, + + @NotBlank(message = "비밀번호는 비어있을 수 없습니다.") + String password, + + @NotBlank(message = "이름은 비어있을 수 없습니다.") + String name, + + @NotNull(message = "생년월일은 비어있을 수 없습니다.") + LocalDate birthDate, + + @NotBlank(message = "이메일은 비어있을 수 없습니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email + ) {} public record SignupResponse(Long id, String loginId, String name, LocalDate birthDate, String email) { - public static SignupResponse from(UserInfo info) { + public static SignupResponse from(User user) { return new SignupResponse( - info.id(), - info.loginId(), - info.name(), - info.birthDate(), - info.email() + user.getId(), + user.getLoginId(), + user.getName(), + user.getBirthDate(), + user.getEmail() ); } } - public record ChangePasswordRequest(String currentPassword, String newPassword) { - } + public record ChangePasswordRequest( + @NotBlank(message = "현재 비밀번호는 비어있을 수 없습니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 비어있을 수 없습니다.") + String newPassword + ) {} public record MeResponse(String loginId, String name, LocalDate birthDate, String email) { - public static MeResponse from(UserInfo info) { + public static MeResponse from(User user) { return new MeResponse( - info.loginId(), - info.name(), - info.birthDate(), - info.email() + user.getLoginId(), + user.getMaskedName(), + user.getBirthDate(), + user.getEmail() ); } } 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 3497b43ce..ec61b7308 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 @@ -2,19 +2,18 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor 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(), "이미 존재하는 리소스입니다."), - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."); + INTERNAL_ERROR(500, "Internal Server Error", "일시적인 오류가 발생했습니다."), + BAD_REQUEST(400, "Bad Request", "잘못된 요청입니다."), + NOT_FOUND(404, "Not Found", "존재하지 않는 요청입니다."), + CONFLICT(409, "Conflict", "이미 존재하는 리소스입니다."), + UNAUTHORIZED(401, "Unauthorized", "인증에 실패했습니다."); - private final HttpStatus status; + private final int statusCode; private final String code; private final String message; } diff --git a/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java new file mode 100644 index 000000000..3a4a5a4bb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java @@ -0,0 +1,107 @@ +package com.loopers; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Repository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; + +@AnalyzeClasses(packages = "com.loopers", importOptions = ImportOption.DoNotIncludeTests.class) +class ArchitectureTest { + + // ── 1. 계층형 아키텍처 의존성 검증 ────────────────────────────────────────── + // Interfaces → Application → Domain ← Infrastructure + // Interfaces → Domain 허용: Controller가 User, PageResult, ProductSortType 등 도메인 타입을 직접 참조 + // Config, Support 패키지는 레이어 외부이므로 검사 대상에서 제외 + @ArchTest + static final ArchRule layered_architecture_is_respected = layeredArchitecture() + .consideringOnlyDependenciesInAnyPackage("com.loopers..") + .layer("Interfaces").definedBy("..interfaces..") + .layer("Application").definedBy("..application..") + .layer("Domain").definedBy("..domain..") + .layer("Infrastructure").definedBy("..infrastructure..") + .layer("Config").definedBy("..config..") + + .whereLayer("Interfaces").mayNotBeAccessedByAnyLayer() + .whereLayer("Application").mayOnlyBeAccessedByLayers("Interfaces") + .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure", "Interfaces", "Config") + .whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer() + .whereLayer("Config").mayNotBeAccessedByAnyLayer(); + + // ── 2. Domain 계층 독립성 (DIP 핵심) ──────────────────────────────────────── + @ArchTest + static final ArchRule domain_should_not_depend_on_infrastructure = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("..infrastructure.."); + + @ArchTest + static final ArchRule domain_should_not_depend_on_application = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("..application.."); + + @ArchTest + static final ArchRule domain_should_not_depend_on_interfaces = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("..interfaces.."); + + // ── 3. 클래스 배치 규칙 (DIP) ─────────────────────────────────────────────── + // Repository 인터페이스는 Domain 패키지에 위치한다 (JpaRepository 제외) + @ArchTest + static final ArchRule repository_interfaces_should_be_in_domain = classes() + .that().haveSimpleNameEndingWith("Repository") + .and().haveSimpleNameNotContaining("Jpa") + .and().areInterfaces() + .should().resideInAPackage("..domain.."); + + // Repository 구현체는 Infrastructure 패키지에 위치한다 + @ArchTest + static final ArchRule repository_implementations_should_be_in_infrastructure = classes() + .that().haveSimpleNameEndingWith("RepositoryImpl") + .should().resideInAPackage("..infrastructure.."); + + // DomainService는 Domain 패키지에 위치한다 + @ArchTest + static final ArchRule domain_services_should_be_in_domain = classes() + .that().haveSimpleNameEndingWith("DomainService") + .should().resideInAPackage("..domain.."); + + // ApplicationService는 Application 패키지에 위치한다 + @ArchTest + static final ArchRule application_services_should_be_in_application = classes() + .that().haveSimpleNameEndingWith("ApplicationService") + .should().resideInAPackage("..application.."); + + // Controller는 Interfaces 패키지에 위치한다 + @ArchTest + static final ArchRule controllers_should_be_in_interfaces = classes() + .that().haveSimpleNameEndingWith("Controller") + .should().resideInAPackage("..interfaces.."); + + // ── 4. Domain 순수성 ──────────────────────────────────────────────────────── + // Domain은 Spring Web 기술에 의존하지 않는다 + @ArchTest + static final ArchRule domain_should_not_depend_on_spring_web = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("..springframework.web.."); + + // Domain에 @Service, @Component, @Repository를 사용하지 않는다 (DomainServiceConfig에서 @Bean 등록) + @ArchTest + static final ArchRule domain_should_not_use_spring_stereotype_annotations = noClasses() + .that().resideInAPackage("..domain..") + .should().beAnnotatedWith(Service.class) + .orShould().beAnnotatedWith(Component.class) + .orShould().beAnnotatedWith(Repository.class); + + // Domain에서 @Transactional을 사용하지 않는다 (트랜잭션은 ApplicationService 책임) + @ArchTest + static final ArchRule domain_should_not_use_transactional = noClasses() + .that().resideInAPackage("..domain..") + .should().beAnnotatedWith(Transactional.class); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/QuantityTest.java new file mode 100644 index 000000000..d112521c7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/QuantityTest.java @@ -0,0 +1,68 @@ +package com.loopers.domain; + +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 QuantityTest { + + @DisplayName("Quantity를 생성할 때, ") + @Nested + class Create { + + @DisplayName("1 이상이면, 정상 생성된다.") + @Test + void createsQuantity_whenValueIsPositive() { + Quantity quantity = new Quantity(5); + assertThat(quantity.value()).isEqualTo(5); + } + + @DisplayName("0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsZero() { + CoreException result = assertThrows(CoreException.class, () -> new Quantity(0)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNegative() { + CoreException result = assertThrows(CoreException.class, () -> new Quantity(-1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("수량을 합산할 때, ") + @Nested + class Add { + + @DisplayName("양수를 합산하면, 값이 증가한다.") + @Test + void addsQuantity_whenAmountIsPositive() { + Quantity quantity = new Quantity(3); + Quantity result = quantity.add(2); + assertThat(result.value()).isEqualTo(5); + } + + @DisplayName("0을 합산하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAmountIsZero() { + Quantity quantity = new Quantity(3); + CoreException result = assertThrows(CoreException.class, () -> quantity.add(0)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("음수를 합산하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAmountIsNegative() { + Quantity quantity = new Quantity(3); + CoreException result = assertThrows(CoreException.class, () -> quantity.add(-1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandDomainServiceIntegrationTest.java new file mode 100644 index 000000000..751dc6f90 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandDomainServiceIntegrationTest.java @@ -0,0 +1,191 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class BrandDomainServiceIntegrationTest { + + @Autowired + private BrandDomainService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("브랜드를 등록할 때, ") + @Nested + class Register { + + @DisplayName("올바른 이름이면, 브랜드가 저장되고 반환된다.") + @Test + void savesAndReturnsBrand_whenNameIsValid() { + // act + Brand result = brandService.register("나이키"); + + // assert + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getName()).isEqualTo("나이키") + ); + } + } + + @DisplayName("브랜드를 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드이면, 브랜드를 반환한다.") + @Test + void returnsBrand_whenBrandExists() { + // arrange + Brand brand = brandService.register("나이키"); + + // act + Brand result = brandService.getById(brand.getId()); + + // assert + assertThat(result.getName()).isEqualTo("나이키"); + } + + @DisplayName("존재하지 않는 브랜드이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + CoreException result = assertThrows(CoreException.class, () -> brandService.getById(999L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("삭제된 브랜드이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandIsDeleted() { + // arrange + Brand brand = brandService.register("나이키"); + brandService.delete(brand.getId()); + + // act + CoreException result = assertThrows(CoreException.class, () -> brandService.getById(brand.getId())); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 목록을 조회할 때, ") + @Nested + class GetAll { + + @DisplayName("브랜드가 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenBrandsExist() { + // arrange + brandService.register("나이키"); + brandService.register("아디다스"); + brandService.register("뉴발란스"); + + // act + PageResult result = brandService.getAll(0, 2); + + // assert + assertAll( + () -> assertThat(result.items()).hasSize(2), + () -> assertThat(result.totalElements()).isEqualTo(3), + () -> assertThat(result.totalPages()).isEqualTo(2), + () -> assertThat(result.page()).isEqualTo(0) + ); + } + + @DisplayName("삭제된 브랜드는 목록에 포함되지 않는다.") + @Test + void excludesDeletedBrands() { + // arrange + Brand brand = brandService.register("나이키"); + brandService.register("아디다스"); + brandService.delete(brand.getId()); + + // act + PageResult result = brandService.getAll(0, 20); + + // assert + assertAll( + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).getName()).isEqualTo("아디다스") + ); + } + } + + @DisplayName("브랜드를 수정할 때, ") + @Nested + class Update { + + @DisplayName("올바른 이름이면, 브랜드 이름이 수정된다.") + @Test + void updatesBrandName_whenNameIsValid() { + // arrange + Brand brand = brandService.register("나이키"); + + // act + Brand result = brandService.update(brand.getId(), "아디다스"); + + // assert + assertThat(result.getName()).isEqualTo("아디다스"); + } + + @DisplayName("존재하지 않는 브랜드이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + CoreException result = assertThrows(CoreException.class, () -> brandService.update(999L, "아디다스")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드를 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드이면, 논리 삭제된다.") + @Test + void softDeletesBrand_whenBrandExists() { + // arrange + Brand brand = brandService.register("나이키"); + + // act + brandService.delete(brand.getId()); + + // assert + CoreException result = assertThrows(CoreException.class, () -> brandService.getById(brand.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("존재하지 않는 브랜드이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + CoreException result = assertThrows(CoreException.class, () -> brandService.delete(999L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} 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..f6f9353ab --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,92 @@ +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.assertThrows; + +class BrandTest { + + @DisplayName("브랜드를 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 이름이면, 정상적으로 생성된다.") + @Test + void createsBrand_whenNameIsValid() { + // act + Brand brand = new Brand("나이키"); + + // assert + assertThat(brand.getName()).isEqualTo("나이키"); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Brand(null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Brand(" ")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("브랜드 이름을 변경할 때, ") + @Nested + class Rename { + + @DisplayName("올바른 이름이면, 정상적으로 수정된다.") + @Test + void updatesBrand_whenNameIsValid() { + // arrange + Brand brand = new Brand("나이키"); + + // act + brand.rename("아디다스"); + + // assert + assertThat(brand.getName()).isEqualTo("아디다스"); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange + Brand brand = new Brand("나이키"); + + // act + CoreException result = assertThrows(CoreException.class, () -> brand.rename(null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // arrange + Brand brand = new Brand("나이키"); + + // act + CoreException result = assertThrows(CoreException.class, () -> brand.rename(" ")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java new file mode 100644 index 000000000..4a300205e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java @@ -0,0 +1,188 @@ +package com.loopers.domain.cart; + +import com.loopers.domain.Quantity; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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 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; + +@SpringBootTest +class CartDomainServiceIntegrationTest { + + @Autowired + private CartDomainService cartService; + + @Autowired + private ProductDomainService productService; + + @Autowired + private BrandDomainService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Long brandId; + private Long productId; + + @BeforeEach + void setUp() { + brandId = brandService.register("나이키").getId(); + Product product = productService.register(brandId, "에어맥스", 129000, 100); + productId = product.getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("장바구니에 상품을 담을 때, ") + @Nested + class AddToCart { + + @DisplayName("새로운 상품이면, 장바구니 항목이 생성된다.") + @Test + void createsCartItem_whenNewProduct() { + cartService.addToCart(1L, productId, 2); + + List items = cartService.getCart(1L).getItems(); + assertAll( + () -> assertThat(items).hasSize(1), + () -> assertThat(items.get(0).getProductId()).isEqualTo(productId), + () -> assertThat(items.get(0).getQuantity()).isEqualTo(new Quantity(2)) + ); + } + + @DisplayName("이미 담긴 상품이면, 수량이 합산된다.") + @Test + void addsQuantity_whenProductAlreadyInCart() { + cartService.addToCart(1L, productId, 2); + + cartService.addToCart(1L, productId, 3); + + List items = cartService.getCart(1L).getItems(); + assertAll( + () -> assertThat(items).hasSize(1), + () -> assertThat(items.get(0).getQuantity()).isEqualTo(new Quantity(5)) + ); + } + } + + @DisplayName("장바구니 수량을 변경할 때, ") + @Nested + class UpdateQuantity { + + @DisplayName("올바른 수량이면, 수량이 변경된다.") + @Test + void updatesQuantity_whenValid() { + cartService.addToCart(1L, productId, 2); + Long cartItemId = cartService.getCart(1L).getItems().get(0).getId(); + + cartService.updateItemQuantity(1L, cartItemId, 5); + + List items = cartService.getCart(1L).getItems(); + assertThat(items.get(0).getQuantity()).isEqualTo(new Quantity(5)); + } + + @DisplayName("장바구니가 없으면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenCartDoesNotExist() { + CoreException result = assertThrows(CoreException.class, + () -> cartService.updateItemQuantity(999L, 1L, 5)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("존재하지 않는 항목이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenItemDoesNotExist() { + cartService.addToCart(1L, productId, 2); + + CoreException result = assertThrows(CoreException.class, + () -> cartService.updateItemQuantity(1L, 999L, 5)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("장바구니 항목을 삭제할 때, ") + @Nested + class RemoveItem { + + @DisplayName("존재하는 항목이면, 삭제된다.") + @Test + void removesItem_whenItemExists() { + cartService.addToCart(1L, productId, 2); + Long cartItemId = cartService.getCart(1L).getItems().get(0).getId(); + + cartService.removeItem(1L, cartItemId); + + List items = cartService.getCart(1L).getItems(); + assertThat(items).isEmpty(); + } + + @DisplayName("장바구니가 없으면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenCartDoesNotExist() { + CoreException result = assertThrows(CoreException.class, + () -> cartService.removeItem(999L, 1L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("장바구니를 조회할 때, ") + @Nested + class GetCartItems { + + @DisplayName("항목이 있으면, 목록을 반환한다.") + @Test + void returnsItems_whenItemsExist() { + Product product2 = productService.register(brandId, "에어포스1", 109000, 200); + cartService.addToCart(1L, productId, 2); + cartService.addToCart(1L, product2.getId(), 1); + + List result = cartService.getCart(1L).getItems(); + + assertThat(result).hasSize(2); + } + + @DisplayName("항목이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoItems() { + List result = cartService.getCart(1L).getItems(); + + assertThat(result).isEmpty(); + } + } + + @DisplayName("장바구니를 비울 때, ") + @Nested + class ClearCart { + + @DisplayName("모든 항목이 삭제된다.") + @Test + void clearsAllItems() { + Product product2 = productService.register(brandId, "에어포스1", 109000, 200); + cartService.addToCart(1L, productId, 2); + cartService.addToCart(1L, product2.getId(), 1); + + cartService.clearCart(1L); + + List result = cartService.getCart(1L).getItems(); + assertThat(result).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java new file mode 100644 index 000000000..fff1e305f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java @@ -0,0 +1,66 @@ +package com.loopers.domain.cart; + +import com.loopers.domain.Quantity; +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; + +class CartItemTest { + + private Cart createCart() { + return new Cart(1L); + } + + @DisplayName("CartItem을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 정보이면, CartItem이 생성된다.") + @Test + void createsCartItem_whenValidInfo() { + Cart cart = createCart(); + cart.addItem(100L, 2); + + CartItem cartItem = cart.getItems().get(0); + + assertThat(cartItem.getProductId()).isEqualTo(100L); + assertThat(cartItem.getQuantity()).isEqualTo(new Quantity(2)); + } + } + + @DisplayName("수량을 합산할 때, ") + @Nested + class AddQuantity { + + @DisplayName("양수를 합산하면, 수량이 증가한다.") + @Test + void addsQuantity_whenAmountIsPositive() { + Cart cart = createCart(); + cart.addItem(100L, 2); + + CartItem cartItem = cart.getItems().get(0); + cartItem.addQuantity(3); + + assertThat(cartItem.getQuantity()).isEqualTo(new Quantity(5)); + } + } + + @DisplayName("수량을 변경할 때, ") + @Nested + class ChangeQuantity { + + @DisplayName("1 이상이면, 수량이 변경된다.") + @Test + void changesQuantity_whenValueIsPositive() { + Cart cart = createCart(); + cart.addItem(100L, 2); + + CartItem cartItem = cart.getItems().get(0); + cartItem.changeQuantity(5); + + assertThat(cartItem.getQuantity()).isEqualTo(new Quantity(5)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartTest.java new file mode 100644 index 000000000..b69457610 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartTest.java @@ -0,0 +1,179 @@ +package com.loopers.domain.cart; + +import com.loopers.domain.Quantity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.Set; + +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 CartTest { + + @DisplayName("Cart를 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 유저 ID이면, Cart가 생성된다.") + @Test + void createsCart_whenUserIdIsValid() { + Cart cart = new Cart(1L); + + assertAll( + () -> assertThat(cart.getUserId()).isEqualTo(1L), + () -> assertThat(cart.getItems()).isEmpty() + ); + } + + @DisplayName("유저 ID가 null이면, 예외가 발생한다.") + @Test + void throwsException_whenUserIdIsNull() { + assertThrows(NullPointerException.class, () -> new Cart(null)); + } + } + + @DisplayName("상품을 담을 때, ") + @Nested + class AddItem { + + @DisplayName("새로운 상품이면, 항목이 추가된다.") + @Test + void addsItem_whenNewProduct() { + Cart cart = new Cart(1L); + + cart.addItem(100L, 2); + + assertAll( + () -> assertThat(cart.getItems()).hasSize(1), + () -> assertThat(cart.getItems().get(0).getProductId()).isEqualTo(100L), + () -> assertThat(cart.getItems().get(0).getQuantity()).isEqualTo(new Quantity(2)) + ); + } + + @DisplayName("이미 담긴 상품이면, 수량이 합산된다.") + @Test + void addsQuantity_whenProductAlreadyExists() { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + + cart.addItem(100L, 3); + + assertAll( + () -> assertThat(cart.getItems()).hasSize(1), + () -> assertThat(cart.getItems().get(0).getQuantity()).isEqualTo(new Quantity(5)) + ); + } + + @DisplayName("다른 상품이면, 별도 항목으로 추가된다.") + @Test + void addsSeparateItem_whenDifferentProduct() { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + + cart.addItem(200L, 3); + + assertThat(cart.getItems()).hasSize(2); + } + } + + @DisplayName("항목을 삭제할 때, ") + @Nested + class RemoveItem { + + @DisplayName("존재하는 항목이면, 삭제된다.") + @Test + void removesItem_whenItemExists() throws Exception { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + + CartItem cartItem = cart.getItems().get(0); + Field idField = CartItem.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(cartItem, 1L); + + cart.removeItem(1L); + + assertThat(cart.getItems()).isEmpty(); + } + + @DisplayName("존재하지 않는 항목이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenItemDoesNotExist() { + Cart cart = new Cart(1L); + + CoreException result = assertThrows(CoreException.class, () -> cart.removeItem(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("수량을 변경할 때, ") + @Nested + class UpdateItemQuantity { + + @DisplayName("존재하지 않는 항목이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenItemDoesNotExist() { + Cart cart = new Cart(1L); + + CoreException result = assertThrows(CoreException.class, + () -> cart.updateItemQuantity(999L, 5)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("장바구니를 비울 때, ") + @Nested + class Clear { + + @DisplayName("모든 항목이 삭제된다.") + @Test + void clearsAllItems() { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + cart.addItem(200L, 3); + + cart.clear(); + + assertThat(cart.getItems()).isEmpty(); + } + } + + @DisplayName("사용 불가능한 상품을 제거할 때, ") + @Nested + class RemoveUnavailableItems { + + @DisplayName("존재하지 않는 상품이 제거된다.") + @Test + void removesUnavailableItems() { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + cart.addItem(200L, 3); + cart.addItem(300L, 1); + + cart.removeUnavailableItems(Set.of(100L, 300L)); + + assertAll( + () -> assertThat(cart.getItems()).hasSize(2), + () -> assertThat(cart.getItems().get(0).getProductId()).isEqualTo(100L), + () -> assertThat(cart.getItems().get(1).getProductId()).isEqualTo(300L) + ); + } + + @DisplayName("모든 상품이 사용 불가능하면, 장바구니가 비워진다.") + @Test + void clearsCart_whenAllItemsUnavailable() { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + + cart.removeUnavailableItems(Set.of(200L)); + + assertThat(cart.getItems()).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceIntegrationTest.java new file mode 100644 index 000000000..bdc442656 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceIntegrationTest.java @@ -0,0 +1,127 @@ +package com.loopers.domain.like; + +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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 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; + +@SpringBootTest +class LikeDomainServiceIntegrationTest { + + @Autowired + private LikeDomainService likeService; + + @Autowired + private ProductDomainService productService; + + @Autowired + private BrandDomainService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Long brandId; + private Long productId; + + @BeforeEach + void setUp() { + brandId = brandService.register("나이키").getId(); + Product product = productService.register(brandId, "에어맥스", 129000, 100); + productId = product.getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("좋아요를 등록할 때, ") + @Nested + class LikeProduct { + + @DisplayName("처음 좋아요하면, 좋아요가 저장된다.") + @Test + void savesLike_whenFirstTime() { + likeService.like(1L, productId); + + List likes = likeService.getMyLikes(1L); + assertAll( + () -> assertThat(likes).hasSize(1), + () -> assertThat(likes.get(0).getUserId()).isEqualTo(1L), + () -> assertThat(likes.get(0).getProductId()).isEqualTo(productId) + ); + } + + @DisplayName("이미 좋아요한 상품이면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflict_whenAlreadyLiked() { + likeService.like(1L, productId); + + CoreException result = assertThrows(CoreException.class, () -> likeService.like(1L, productId)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("좋아요를 취소할 때, ") + @Nested + class UnlikeProduct { + + @DisplayName("좋아요가 존재하면, 삭제된다.") + @Test + void deletesLike_whenLikeExists() { + likeService.like(1L, productId); + + likeService.unlike(1L, productId); + + List likes = likeService.getMyLikes(1L); + assertThat(likes).isEmpty(); + } + + @DisplayName("좋아요가 존재하지 않으면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenLikeDoesNotExist() { + CoreException result = assertThrows(CoreException.class, () -> likeService.unlike(1L, productId)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("내 좋아요 목록을 조회할 때, ") + @Nested + class GetMyLikes { + + @DisplayName("좋아요한 상품이 있으면, 목록을 반환한다.") + @Test + void returnsLikes_whenLikesExist() { + Product product2 = productService.register(brandId, "에어포스1", 109000, 200); + likeService.like(1L, productId); + likeService.like(1L, product2.getId()); + + List result = likeService.getMyLikes(1L); + + assertThat(result).hasSize(2); + } + + @DisplayName("좋아요한 상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoLikes() { + List result = likeService.getMyLikes(1L); + + assertThat(result).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..d6ceb9b98 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,44 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LikeTest { + + @DisplayName("좋아요를 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 정보이면, 좋아요가 생성된다.") + @Test + void createsLike_whenValidInfo() { + Like like = new Like(1L, 100L); + + assertAll( + () -> assertThat(like.getUserId()).isEqualTo(1L), + () -> assertThat(like.getProductId()).isEqualTo(100L) + ); + } + + @DisplayName("userId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUserIdIsNull() { + CoreException result = assertThrows(CoreException.class, () -> new Like(null, 100L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + CoreException result = assertThrows(CoreException.class, () -> new Like(1L, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java new file mode 100644 index 000000000..c2d754e7c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java @@ -0,0 +1,69 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; + +import java.lang.reflect.Field; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +class FakeOrderRepository implements OrderRepository { + + private final List store = new ArrayList<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Order save(Order order) { + setId(order, idGenerator.getAndIncrement()); + store.add(order); + return order; + } + + @Override + public Optional findById(Long id) { + return store.stream().filter(o -> o.getId().equals(id)).findFirst(); + } + + @Override + public Optional findByIdWithItems(Long id) { + return findById(id); + } + + @Override + public PageResult findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, int page, int size) { + List filtered = store.stream() + .filter(o -> o.getUserId().equals(userId)) + .filter(o -> o.getCreatedAt() != null + && !o.getCreatedAt().isBefore(startAt) + && o.getCreatedAt().isBefore(endAt)) + .toList(); + int total = filtered.size(); + int fromIndex = Math.min(page * size, total); + int toIndex = Math.min(fromIndex + size, total); + List paged = filtered.subList(fromIndex, toIndex); + int totalPages = (int) Math.ceil((double) total / size); + return new PageResult<>(paged, page, size, total, totalPages); + } + + @Override + public PageResult findAll(int page, int size) { + int total = store.size(); + int fromIndex = Math.min(page * size, total); + int toIndex = Math.min(fromIndex + size, total); + List paged = store.subList(fromIndex, toIndex); + int totalPages = (int) Math.ceil((double) total / size); + return new PageResult<>(paged, page, size, total, totalPages); + } + + private void setId(Order order, long id) { + try { + Field idField = order.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(order, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set Order id", e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceIntegrationTest.java new file mode 100644 index 000000000..11e4ab030 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceIntegrationTest.java @@ -0,0 +1,226 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; +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; + +@SpringBootTest +class OrderDomainServiceIntegrationTest { + + @Autowired + private OrderDomainService orderService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Order createTestOrder(Long userId) { + List items = List.of( + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 2) + ); + return orderService.createOrder(userId, items); + } + + @DisplayName("주문을 생성할 때, ") + @Nested + class CreateOrder { + + @DisplayName("올바른 정보이면, 주문과 주문 항목이 생성되고 총 금액이 계산된다.") + @Test + void createsOrderAndItems_whenValidInfo() { + List items = List.of( + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 2), + new OrderItemCommand(2L, "에어포스1", new Money(109000), "나이키", 1) + ); + + Order order = orderService.createOrder(1L, items); + + assertAll( + () -> assertThat(order.getId()).isNotNull(), + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice()).isEqualTo(new Money(367000)), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED) + ); + + List orderItems = order.getItems(); + assertAll( + () -> assertThat(orderItems).hasSize(2), + () -> assertThat(orderItems.get(0).getProductName()).isEqualTo("에어맥스"), + () -> assertThat(orderItems.get(0).getBrandName()).isEqualTo("나이키"), + () -> assertThat(orderItems.get(1).getProductName()).isEqualTo("에어포스1") + ); + } + + @DisplayName("주문 항목이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenItemsEmpty() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, List.of())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 항목이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenItemsNull() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("중복된 상품이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenDuplicateProducts() { + List items = List.of( + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 2), + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 3) + ); + + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, items)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문을 ID로 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 주문이면, 주문을 반환한다.") + @Test + void returnsOrder_whenOrderExists() { + Order created = createTestOrder(1L); + + Order result = orderService.getById(created.getId()); + + assertThat(result.getId()).isEqualTo(created.getId()); + } + + @DisplayName("존재하지 않는 주문이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenOrderDoesNotExist() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.getById(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("주문을 ID로 항목과 함께 조회할 때, ") + @Nested + class GetByIdWithItems { + + @DisplayName("존재하는 주문이면, 주문과 항목을 반환한다.") + @Test + void returnsOrderWithItems_whenOrderExists() { + Order created = createTestOrder(1L); + + Order result = orderService.getByIdWithItems(created.getId()); + + assertAll( + () -> assertThat(result.getId()).isEqualTo(created.getId()), + () -> assertThat(result.getItems()).hasSize(1), + () -> assertThat(result.getItems().get(0).getProductName()).isEqualTo("에어맥스") + ); + } + } + + @DisplayName("유저의 주문을 조회할 때, ") + @Nested + class GetByIdAndUserId { + + @DisplayName("본인의 주문이면, 주문을 반환한다.") + @Test + void returnsOrder_whenOwner() { + Order created = createTestOrder(1L); + + Order result = orderService.getByIdAndUserId(created.getId(), 1L); + + assertThat(result.getId()).isEqualTo(created.getId()); + } + + @DisplayName("다른 유저의 주문이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotOwner() { + Order created = createTestOrder(1L); + + CoreException result = assertThrows(CoreException.class, + () -> orderService.getByIdAndUserId(created.getId(), 999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("내 주문 목록을 조회할 때, ") + @Nested + class GetMyOrders { + + @DisplayName("기간 내 주문이 있으면, 목록을 반환한다.") + @Test + void returnsOrders_whenOrdersExistInRange() { + createTestOrder(1L); + createTestOrder(1L); + createTestOrder(2L); + + LocalDate start = LocalDate.now().minusDays(1); + LocalDate end = LocalDate.now().plusDays(1); + + PageResult result = orderService.getMyOrders(1L, start, end, 0, 20); + + assertAll( + () -> assertThat(result.items()).hasSize(2), + () -> assertThat(result.totalElements()).isEqualTo(2) + ); + } + + @DisplayName("기간 내 주문이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoOrdersInRange() { + createTestOrder(1L); + + LocalDate start = LocalDate.now().plusDays(1); + LocalDate end = LocalDate.now().plusDays(2); + + PageResult result = orderService.getMyOrders(1L, start, end, 0, 20); + + assertThat(result.items()).isEmpty(); + } + } + + @DisplayName("전체 주문 목록을 조회할 때, ") + @Nested + class GetAllOrders { + + @DisplayName("주문이 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenOrdersExist() { + createTestOrder(1L); + createTestOrder(2L); + createTestOrder(3L); + + PageResult result = orderService.getAllOrders(0, 2); + + assertAll( + () -> assertThat(result.items()).hasSize(2), + () -> assertThat(result.totalElements()).isEqualTo(3), + () -> assertThat(result.totalPages()).isEqualTo(2) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java new file mode 100644 index 000000000..7473785f2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java @@ -0,0 +1,143 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +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 OrderDomainServiceTest { + + private OrderDomainService orderService; + + @BeforeEach + void setUp() { + orderService = new OrderDomainService(new FakeOrderRepository()); + } + + private List createValidItems() { + return List.of( + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 2), + new OrderItemCommand(2L, "에어포스1", new Money(109000), "나이키", 1) + ); + } + + @DisplayName("주문을 생성할 때, ") + @Nested + class CreateOrder { + + @DisplayName("올바른 정보이면, 주문이 생성되고 총 가격이 계산된다.") + @Test + void createsOrder_whenValidInfo() { + List items = createValidItems(); + + Order order = orderService.createOrder(1L, items); + + assertAll( + () -> assertThat(order.getId()).isNotNull(), + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice()).isEqualTo(new Money(367000)), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED), + () -> assertThat(order.getItems()).hasSize(2) + ); + } + + @DisplayName("빈 항목이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenItemsEmpty() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, List.of())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null 항목이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenItemsNull() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("중복된 상품이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenDuplicateProducts() { + List items = List.of( + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 2), + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 3) + ); + + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, items)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("총 가격이 정확히 계산된다.") + @Test + void calculatesTotalPrice_correctly() { + List items = List.of( + new OrderItemCommand(1L, "상품A", new Money(10000), "브랜드A", 3), + new OrderItemCommand(2L, "상품B", new Money(20000), "브랜드B", 2) + ); + + Order order = orderService.createOrder(1L, items); + + assertThat(order.getTotalPrice()).isEqualTo(new Money(70000)); + } + } + + @DisplayName("주문을 ID로 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 주문이면, 주문을 반환한다.") + @Test + void returnsOrder_whenOrderExists() { + Order created = orderService.createOrder(1L, createValidItems()); + + Order result = orderService.getById(created.getId()); + + assertThat(result.getId()).isEqualTo(created.getId()); + } + + @DisplayName("존재하지 않는 주문이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenOrderDoesNotExist() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.getById(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("유저의 주문을 조회할 때, ") + @Nested + class GetByIdAndUserId { + + @DisplayName("본인의 주문이면, 주문을 반환한다.") + @Test + void returnsOrder_whenOwner() { + Order created = orderService.createOrder(1L, createValidItems()); + + Order result = orderService.getByIdAndUserId(created.getId(), 1L); + + assertThat(result.getId()).isEqualTo(created.getId()); + } + + @DisplayName("다른 유저의 주문이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotOwner() { + Order created = orderService.createOrder(1L, createValidItems()); + + CoreException result = assertThrows(CoreException.class, + () -> orderService.getByIdAndUserId(created.getId(), 999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} 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..659d59ee2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,93 @@ +package com.loopers.domain.order; + +import com.loopers.domain.Quantity; +import com.loopers.domain.product.Money; +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; + +class OrderItemTest { + + private Order createTestOrder() { + return new Order(1L, new Money(129000)); + } + + @DisplayName("OrderItem을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 정보이면, OrderItem이 생성된다.") + @Test + void createsOrderItem_whenValidInfo() { + Order order = createTestOrder(); + OrderItem orderItem = new OrderItem(order, 100L, "에어맥스", new Money(129000), "나이키", 2); + + assertAll( + () -> assertThat(orderItem.getProductId()).isEqualTo(100L), + () -> assertThat(orderItem.getProductName()).isEqualTo("에어맥스"), + () -> assertThat(orderItem.getProductPrice()).isEqualTo(new Money(129000)), + () -> assertThat(orderItem.getBrandName()).isEqualTo("나이키"), + () -> assertThat(orderItem.getQuantity()).isEqualTo(new Quantity(2)) + ); + } + + @DisplayName("order가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenOrderIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(null, 100L, "에어맥스", new Money(129000), "나이키", 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + Order order = createTestOrder(); + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(order, null, "에어맥스", new Money(129000), "나이키", 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품 이름이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductNameIsBlank() { + Order order = createTestOrder(); + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(order, 100L, "", new Money(129000), "나이키", 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품 가격이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductPriceIsNull() { + Order order = createTestOrder(); + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(order, 100L, "에어맥스", null, "나이키", 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("브랜드 이름이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandNameIsBlank() { + Order order = createTestOrder(); + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(order, 100L, "에어맥스", new Money(129000), "", 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsZero() { + Order order = createTestOrder(); + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(order, 100L, "에어맥스", new Money(129000), "나이키", 0)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderPolicyTest.java new file mode 100644 index 000000000..9fa0f9a90 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderPolicyTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderPolicyTest { + + @DisplayName("중복 상품 검증할 때, ") + @Nested + class ValidateNoDuplicateProducts { + + @DisplayName("중복이 없으면, 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenNoDuplicates() { + List productIds = List.of(1L, 2L, 3L); + + assertThatCode(() -> OrderPolicy.validateNoDuplicateProducts(productIds)) + .doesNotThrowAnyException(); + } + + @DisplayName("중복된 상품 ID가 있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenDuplicateProductIds() { + List productIds = List.of(1L, 2L, 1L); + + CoreException result = assertThrows(CoreException.class, + () -> OrderPolicy.validateNoDuplicateProducts(productIds)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("단일 상품이면, 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenSingleProduct() { + List productIds = List.of(1L); + + assertThatCode(() -> OrderPolicy.validateNoDuplicateProducts(productIds)) + .doesNotThrowAnyException(); + } + } +} 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..9d17346b9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,98 @@ +package com.loopers.domain.order; + +import com.loopers.domain.Quantity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderTest { + + @DisplayName("Order를 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 정보이면, Order가 생성된다.") + @Test + void createsOrder_whenValidInfo() { + Order order = new Order(1L, new Money(50000)); + + assertAll( + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice()).isEqualTo(new Money(50000)), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED) + ); + } + + @DisplayName("userId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUserIdIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new Order(null, new Money(50000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("totalPrice가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenTotalPriceIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new Order(1L, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문 항목을 추가할 때, ") + @Nested + class AddItems { + + @DisplayName("올바른 항목이면, 주문 항목이 추가된다.") + @Test + void addsItems_whenValidCommands() { + Order order = new Order(1L, new Money(50000)); + List commands = List.of( + new OrderItemCommand(1L, "에어맥스", new Money(25000), "나이키", 2) + ); + + order.addItems(commands); + + assertAll( + () -> assertThat(order.getItems()).hasSize(1), + () -> assertThat(order.getItems().get(0).getProductName()).isEqualTo("에어맥스"), + () -> assertThat(order.getItems().get(0).getQuantity()).isEqualTo(new Quantity(2)) + ); + } + } + + @DisplayName("주문을 취소할 때, ") + @Nested + class Cancel { + + @DisplayName("ORDERED 상태이면, 취소된다.") + @Test + void cancelsOrder_whenStatusIsOrdered() { + Order order = new Order(1L, new Money(50000)); + + order.cancel(); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @DisplayName("이미 취소된 주문이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAlreadyCancelled() { + Order order = new Order(1L, new Money(50000)); + order.cancel(); + + CoreException result = assertThrows(CoreException.class, order::cancel); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java new file mode 100644 index 000000000..9960af30e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java @@ -0,0 +1,87 @@ +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.assertThrows; + +class MoneyTest { + + @DisplayName("Money를 생성할 때, ") + @Nested + class Create { + + @DisplayName("0 이상의 금액이면, 정상 생성된다.") + @Test + void createsMoney_whenAmountIsZeroOrPositive() { + Money money = new Money(1000); + assertThat(money.amount()).isEqualTo(1000); + } + + @DisplayName("0원이면, 정상 생성된다.") + @Test + void createsMoney_whenAmountIsZero() { + Money money = new Money(0); + assertThat(money.amount()).isEqualTo(0); + } + + @DisplayName("음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAmountIsNegative() { + CoreException result = assertThrows(CoreException.class, () -> new Money(-1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("Money 연산할 때, ") + @Nested + class Operations { + + @DisplayName("더하면, 합산된 금액을 반환한다.") + @Test + void returnsSum_whenAdding() { + Money a = new Money(1000); + Money b = new Money(2000); + assertThat(a.plus(b)).isEqualTo(new Money(3000)); + } + + @DisplayName("빼면, 차감된 금액을 반환한다.") + @Test + void returnsDifference_whenSubtracting() { + Money a = new Money(3000); + Money b = new Money(1000); + assertThat(a.minus(b)).isEqualTo(new Money(2000)); + } + + @DisplayName("빼서 음수가 되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenMinusResultsInNegative() { + Money a = new Money(1000); + Money b = new Money(3000); + CoreException result = assertThrows(CoreException.class, () -> a.minus(b)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).isEqualTo("금액은 음수가 될 수 없습니다."); + } + + @DisplayName("곱하면, 곱셈된 금액을 반환한다.") + @Test + void returnsProduct_whenMultiplying() { + Money money = new Money(1000); + assertThat(money.multiply(3)).isEqualTo(new Money(3000)); + } + + @DisplayName("크거나 같은지 비교할 수 있다.") + @Test + void comparesGreaterThanOrEqual() { + Money a = new Money(3000); + Money b = new Money(2000); + assertThat(a.isGreaterThanOrEqual(b)).isTrue(); + assertThat(b.isGreaterThanOrEqual(a)).isFalse(); + assertThat(a.isGreaterThanOrEqual(new Money(3000))).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java new file mode 100644 index 000000000..cdfe16207 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java @@ -0,0 +1,309 @@ +package com.loopers.domain.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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.transaction.support.TransactionTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class ProductDomainServiceIntegrationTest { + + @Autowired + private ProductDomainService productService; + + @Autowired + private BrandDomainService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private TransactionTemplate transactionTemplate; + + private Long brandId; + + @BeforeEach + void setUp() { + Brand brand = brandService.register("나이키"); + brandId = brand.getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("상품을 등록할 때, ") + @Nested + class Register { + + @DisplayName("올바른 정보이면, 상품이 저장되고 반환된다.") + @Test + void savesAndReturnsProduct_whenValidInfo() { + Product result = productService.register(brandId, "에어맥스", 129000, 100); + + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getBrandId()).isEqualTo(brandId), + () -> assertThat(result.getName()).isEqualTo("에어맥스"), + () -> assertThat(result.getPrice()).isEqualTo(new Money(129000)), + () -> assertThat(result.getStock()).isEqualTo(new Stock(100)), + () -> assertThat(result.getLikeCount()).isEqualTo(0) + ); + } + } + + @DisplayName("상품을 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 상품이면, 상품을 반환한다.") + @Test + void returnsProduct_whenProductExists() { + Product product = productService.register(brandId, "에어맥스", 129000, 100); + + Product result = productService.getById(product.getId()); + + assertThat(result.getName()).isEqualTo("에어맥스"); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + CoreException result = assertThrows(CoreException.class, () -> productService.getById(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("삭제된 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductIsDeleted() { + Product product = productService.register(brandId, "에어맥스", 129000, 100); + productService.delete(product.getId()); + + CoreException result = assertThrows(CoreException.class, () -> productService.getById(product.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 목록을 조회할 때, ") + @Nested + class GetAll { + + @DisplayName("상품이 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenProductsExist() { + productService.register(brandId, "에어맥스", 129000, 100); + productService.register(brandId, "울트라부스트", 159000, 50); + productService.register(brandId, "뉴발란스 990", 199000, 30); + + PageResult result = productService.getAll(null, ProductSortType.LATEST, 0, 2); + + assertAll( + () -> assertThat(result.items()).hasSize(2), + () -> assertThat(result.totalElements()).isEqualTo(3), + () -> assertThat(result.totalPages()).isEqualTo(2) + ); + } + + @DisplayName("브랜드별 필터링이 동작한다.") + @Test + void filtersByBrandId() { + Brand brand2 = brandService.register("아디다스"); + productService.register(brandId, "에어맥스", 129000, 100); + productService.register(brand2.getId(), "울트라부스트", 159000, 50); + + PageResult result = productService.getAll(brandId, ProductSortType.LATEST, 0, 20); + + assertAll( + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).getName()).isEqualTo("에어맥스") + ); + } + + @DisplayName("가격 오름차순으로 정렬된다.") + @Test + void sortsByPriceAsc() { + productService.register(brandId, "비싼상품", 199000, 30); + productService.register(brandId, "싼상품", 99000, 100); + productService.register(brandId, "중간상품", 149000, 50); + + PageResult result = productService.getAll(null, ProductSortType.PRICE_ASC, 0, 20); + + assertAll( + () -> assertThat(result.items()).hasSize(3), + () -> assertThat(result.items().get(0).getName()).isEqualTo("싼상품"), + () -> assertThat(result.items().get(1).getName()).isEqualTo("중간상품"), + () -> assertThat(result.items().get(2).getName()).isEqualTo("비싼상품") + ); + } + + @DisplayName("좋아요 내림차순으로 정렬된다.") + @Test + void sortsByLikesDesc() { + Product p1 = productService.register(brandId, "인기없는상품", 129000, 100); + Product p2 = productService.register(brandId, "인기상품", 159000, 50); + transactionTemplate.executeWithoutResult(status -> { + productService.incrementLikeCount(p2.getId()); + productService.incrementLikeCount(p2.getId()); + productService.incrementLikeCount(p1.getId()); + }); + + PageResult result = productService.getAll(null, ProductSortType.LIKES_DESC, 0, 20); + + assertAll( + () -> assertThat(result.items()).hasSize(2), + () -> assertThat(result.items().get(0).getName()).isEqualTo("인기상품"), + () -> assertThat(result.items().get(1).getName()).isEqualTo("인기없는상품") + ); + } + + @DisplayName("삭제된 상품은 목록에 포함되지 않는다.") + @Test + void excludesDeletedProducts() { + Product product = productService.register(brandId, "에어맥스", 129000, 100); + productService.register(brandId, "울트라부스트", 159000, 50); + productService.delete(product.getId()); + + PageResult result = productService.getAll(null, ProductSortType.LATEST, 0, 20); + + assertAll( + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).getName()).isEqualTo("울트라부스트") + ); + } + } + + @DisplayName("상품을 수정할 때, ") + @Nested + class Update { + + @DisplayName("올바른 정보이면, 상품이 수정된다.") + @Test + void updatesProduct_whenValidInfo() { + Product product = productService.register(brandId, "에어맥스", 129000, 100); + + Product result = productService.update(product.getId(), "에어포스1", 109000, 200); + + assertAll( + () -> assertThat(result.getName()).isEqualTo("에어포스1"), + () -> assertThat(result.getPrice()).isEqualTo(new Money(109000)), + () -> assertThat(result.getStock()).isEqualTo(new Stock(200)) + ); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + CoreException result = assertThrows(CoreException.class, + () -> productService.update(999L, "에어맥스", 129000, 100)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품을 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("존재하는 상품이면, 논리 삭제된다.") + @Test + void softDeletesProduct_whenProductExists() { + Product product = productService.register(brandId, "에어맥스", 129000, 100); + + productService.delete(product.getId()); + + CoreException result = assertThrows(CoreException.class, () -> productService.getById(product.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + CoreException result = assertThrows(CoreException.class, () -> productService.delete(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 삭제 시, ") + @Nested + class DeleteAllByBrand { + + @DisplayName("해당 브랜드의 모든 상품이 삭제된다.") + @Test + void deletesAllProductsOfBrand() { + productService.register(brandId, "에어맥스", 129000, 100); + productService.register(brandId, "에어포스1", 109000, 200); + + transactionTemplate.executeWithoutResult(status -> + productService.deleteAllByBrandId(brandId) + ); + + PageResult result = productService.getAll(brandId, ProductSortType.LATEST, 0, 20); + assertThat(result.items()).isEmpty(); + } + } + + @DisplayName("좋아요 수를 증가할 때, ") + @Nested + class IncrementLikeCount { + + @DisplayName("좋아요 수가 1 증가한다.") + @Test + void incrementsLikeCount() { + Product product = productService.register(brandId, "에어맥스", 129000, 100); + + transactionTemplate.executeWithoutResult(status -> + productService.incrementLikeCount(product.getId()) + ); + + Product result = productService.getById(product.getId()); + assertThat(result.getLikeCount()).isEqualTo(1); + } + } + + @DisplayName("좋아요 수를 감소할 때, ") + @Nested + class DecrementLikeCount { + + @DisplayName("좋아요 수가 1 감소한다.") + @Test + void decrementsLikeCount() { + Product product = productService.register(brandId, "에어맥스", 129000, 100); + transactionTemplate.executeWithoutResult(status -> + productService.incrementLikeCount(product.getId()) + ); + + transactionTemplate.executeWithoutResult(status -> + productService.decrementLikeCount(product.getId()) + ); + + Product result = productService.getById(product.getId()); + assertThat(result.getLikeCount()).isEqualTo(0); + } + + @DisplayName("좋아요 수가 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLikeCountIsZero() { + Product product = productService.register(brandId, "에어맥스", 129000, 100); + + CoreException result = assertThrows(CoreException.class, + () -> transactionTemplate.executeWithoutResult(status -> + productService.decrementLikeCount(product.getId()) + )); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductSortTypeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductSortTypeTest.java new file mode 100644 index 000000000..5199f4cdb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductSortTypeTest.java @@ -0,0 +1,56 @@ +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.assertThrows; + +class ProductSortTypeTest { + + @DisplayName("ProductSortType.from()을 호출할 때, ") + @Nested + class From { + + @DisplayName("null이면, LATEST를 반환한다.") + @Test + void returnsLatest_whenNull() { + assertThat(ProductSortType.from(null)).isEqualTo(ProductSortType.LATEST); + } + + @DisplayName("빈 문자열이면, LATEST를 반환한다.") + @Test + void returnsLatest_whenBlank() { + assertThat(ProductSortType.from("")).isEqualTo(ProductSortType.LATEST); + } + + @DisplayName("소문자 'latest'이면, LATEST를 반환한다.") + @Test + void returnsLatest_whenLowercase() { + assertThat(ProductSortType.from("latest")).isEqualTo(ProductSortType.LATEST); + } + + @DisplayName("대문자 'PRICE_ASC'이면, PRICE_ASC를 반환한다.") + @Test + void returnsPriceAsc_whenUppercase() { + assertThat(ProductSortType.from("PRICE_ASC")).isEqualTo(ProductSortType.PRICE_ASC); + } + + @DisplayName("소문자 'likes_desc'이면, LIKES_DESC를 반환한다.") + @Test + void returnsLikesDesc_whenLowercase() { + assertThat(ProductSortType.from("likes_desc")).isEqualTo(ProductSortType.LIKES_DESC); + } + + @DisplayName("유효하지 않은 값이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenInvalid() { + CoreException result = assertThrows(CoreException.class, + () -> ProductSortType.from("invalid")); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..eb338eab3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,168 @@ +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; + +class ProductTest { + + @DisplayName("상품을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 정보이면, 상품이 생성된다.") + @Test + void createsProduct_whenValidInfo() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(100)); + + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(1L), + () -> assertThat(product.getName()).isEqualTo("나이키 에어맥스"), + () -> assertThat(product.getPrice()).isEqualTo(new Money(129000)), + () -> assertThat(product.getStock()).isEqualTo(new Stock(100)), + () -> assertThat(product.getLikeCount()).isEqualTo(0) + ); + } + + @DisplayName("brandId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandIdIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new Product(null, "나이키 에어맥스", new Money(129000), new Stock(100))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + CoreException result = assertThrows(CoreException.class, + () -> new Product(1L, "", new Money(129000), new Stock(100))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new Product(1L, "나이키 에어맥스", null, new Stock(100))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new Product(1L, "나이키 에어맥스", new Money(129000), null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상품 정보를 변경할 때, ") + @Nested + class ChangeDetails { + + @DisplayName("올바른 정보이면, 이름/가격/재고가 수정된다.") + @Test + void changesDetails_whenValidInfo() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(100)); + product.changeDetails("아디다스 울트라부스트", new Money(159000), new Stock(50)); + + assertAll( + () -> assertThat(product.getName()).isEqualTo("아디다스 울트라부스트"), + () -> assertThat(product.getPrice()).isEqualTo(new Money(159000)), + () -> assertThat(product.getStock()).isEqualTo(new Stock(50)) + ); + } + + @DisplayName("brandId는 변경되지 않는다.") + @Test + void doesNotChangeBrandId() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(100)); + product.changeDetails("아디다스 울트라부스트", new Money(159000), new Stock(50)); + + assertThat(product.getBrandId()).isEqualTo(1L); + } + } + + @DisplayName("재고를 차감할 때, ") + @Nested + class DeductStock { + + @DisplayName("충분한 재고가 있으면, 재고가 차감된다.") + @Test + void deductsStock_whenSufficient() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); + product.deductStock(3); + + assertThat(product.getStock()).isEqualTo(new Stock(7)); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenInsufficient() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(2)); + + CoreException result = assertThrows(CoreException.class, () -> product.deductStock(3)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("좋아요 수를 증가시킬 때, ") + @Nested + class IncrementLikeCount { + + @DisplayName("좋아요 수가 1 증가한다.") + @Test + void incrementsLikeCount() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); + + product.incrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("여러 번 호출하면, 호출 횟수만큼 증가한다.") + @Test + void incrementsMultipleTimes() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); + + product.incrementLikeCount(); + product.incrementLikeCount(); + product.incrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(3); + } + } + + @DisplayName("좋아요 수를 감소시킬 때, ") + @Nested + class DecrementLikeCount { + + @DisplayName("좋아요 수가 1보다 크면, 1 감소한다.") + @Test + void decrementsLikeCount() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); + product.incrementLikeCount(); + product.incrementLikeCount(); + + product.decrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 수가 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLikeCountIsZero() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); + + CoreException result = assertThrows(CoreException.class, product::decrementLikeCount); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java new file mode 100644 index 000000000..4d7cf6fd6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java @@ -0,0 +1,68 @@ +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.assertThrows; + +class StockTest { + + @DisplayName("Stock을 생성할 때, ") + @Nested + class Create { + + @DisplayName("0 이상이면, 정상 생성된다.") + @Test + void createsStock_whenQuantityIsZeroOrPositive() { + Stock stock = new Stock(10); + assertThat(stock.quantity()).isEqualTo(10); + } + + @DisplayName("0이면, 정상 생성된다.") + @Test + void createsStock_whenQuantityIsZero() { + Stock stock = new Stock(0); + assertThat(stock.quantity()).isEqualTo(0); + } + + @DisplayName("음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsNegative() { + CoreException result = assertThrows(CoreException.class, () -> new Stock(-1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고를 차감할 때, ") + @Nested + class Deduct { + + @DisplayName("충분한 재고가 있으면, 차감된 Stock을 반환한다.") + @Test + void returnsDeductedStock_whenSufficient() { + Stock stock = new Stock(10); + Stock result = stock.deduct(3); + assertThat(result.quantity()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenInsufficient() { + Stock stock = new Stock(2); + CoreException result = assertThrows(CoreException.class, () -> stock.deduct(3)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고와 동일한 수량이면, 0이 된다.") + @Test + void returnsZero_whenExactAmount() { + Stock stock = new Stock(5); + Stock result = stock.deduct(5); + assertThat(result.quantity()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java new file mode 100644 index 000000000..9b365444d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -0,0 +1,86 @@ +package com.loopers.domain.user; + +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 EmailTest { + + @DisplayName("Email을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 형식이면, 정상적으로 생성된다.") + @Test + void createsEmail_whenFormatIsValid() { + // act + Email email = new Email("test@example.com"); + + // assert + assertThat(email.getValue()).isEqualTo("test@example.com"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Email(null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Email(" ")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일 형식이 올바르지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenFormatIsInvalid() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Email("invalid-email")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("동등성을 비교할 때, ") + @Nested + class Equality { + + @DisplayName("같은 값이면 동일한 객체로 판단한다.") + @Test + void isEqual_whenValuesAreSame() { + // arrange + Email email1 = new Email("test@example.com"); + Email email2 = new Email("test@example.com"); + + // assert + assertThat(email1).isEqualTo(email2); + assertThat(email1.hashCode()).isEqualTo(email2.hashCode()); + } + + @DisplayName("다른 값이면 다른 객체로 판단한다.") + @Test + void isNotEqual_whenValuesAreDifferent() { + // arrange + Email email1 = new Email("test1@example.com"); + Email email2 = new Email("test2@example.com"); + + // assert + assertThat(email1).isNotEqualTo(email2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java new file mode 100644 index 000000000..44c82fc89 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java @@ -0,0 +1,86 @@ +package com.loopers.domain.user; + +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 LoginIdTest { + + @DisplayName("LoginId를 생성할 때, ") + @Nested + class Create { + + @DisplayName("영문과 숫자로 구성된 값이면, 정상적으로 생성된다.") + @Test + void createsLoginId_whenValueIsAlphanumeric() { + // act + LoginId loginId = new LoginId("testUser1"); + + // assert + assertThat(loginId.getValue()).isEqualTo("testUser1"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> new LoginId(null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> new LoginId(" ")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("특수문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueContainsSpecialChars() { + // act + CoreException result = assertThrows(CoreException.class, () -> new LoginId("user@123")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("동등성을 비교할 때, ") + @Nested + class Equality { + + @DisplayName("같은 값이면 동일한 객체로 판단한다.") + @Test + void isEqual_whenValuesAreSame() { + // arrange + LoginId loginId1 = new LoginId("testUser1"); + LoginId loginId2 = new LoginId("testUser1"); + + // assert + assertThat(loginId1).isEqualTo(loginId2); + assertThat(loginId1.hashCode()).isEqualTo(loginId2.hashCode()); + } + + @DisplayName("다른 값이면 다른 객체로 판단한다.") + @Test + void isNotEqual_whenValuesAreDifferent() { + // arrange + LoginId loginId1 = new LoginId("testUser1"); + LoginId loginId2 = new LoginId("testUser2"); + + // assert + assertThat(loginId1).isNotEqualTo(loginId2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java new file mode 100644 index 000000000..98d60ea41 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -0,0 +1,74 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PasswordTest { + + @DisplayName("Password를 생성할 때, ") + @Nested + class Create { + + @DisplayName("암호화된 값이 주어지면, 정상적으로 생성된다.") + @Test + void createsPassword_whenEncryptedValueProvided() { + // act + Password password = new Password("$2a$10$encryptedValue"); + + // assert + assertThat(password.getEncryptedValue()).isEqualTo("$2a$10$encryptedValue"); + } + + @DisplayName("null이면, CoreException이 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // act & assert + assertThatThrownBy(() -> new Password(null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @DisplayName("빈 문자열이면, CoreException이 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // act & assert + assertThatThrownBy(() -> new Password("")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } + + @DisplayName("동등성을 비교할 때, ") + @Nested + class Equality { + + @DisplayName("같은 값이면 동일한 객체로 판단한다.") + @Test + void isEqual_whenValuesAreSame() { + // arrange + Password password1 = new Password("encrypted1"); + Password password2 = new Password("encrypted1"); + + // assert + assertThat(password1).isEqualTo(password2); + assertThat(password1.hashCode()).isEqualTo(password2.hashCode()); + } + + @DisplayName("다른 값이면 다른 객체로 판단한다.") + @Test + void isNotEqual_whenValuesAreDifferent() { + // arrange + Password password1 = new Password("encrypted1"); + Password password2 = new Password("encrypted2"); + + // assert + assertThat(password1).isNotEqualTo(password2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java similarity index 93% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java index b7a2ad436..2edfd7617 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java @@ -1,6 +1,5 @@ package com.loopers.domain.user; -import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -18,13 +17,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest -class UserServiceIntegrationTest { +class UserDomainServiceIntegrationTest { @Autowired - private UserService userService; - - @Autowired - private UserJpaRepository userJpaRepository; + private UserDomainService userService; @Autowired private PasswordEncryptor passwordEncryptor; @@ -105,7 +101,7 @@ void changesPassword_whenCurrentPasswordIsCorrectAndNewPasswordIsValid() { String newPassword = "NewPass123!"; // act - userService.changePassword(user, RAW_PASSWORD, newPassword); + userService.changePassword(user.getId(), RAW_PASSWORD, newPassword); // assert User updated = userService.authenticate(LOGIN_ID, newPassword); @@ -120,7 +116,7 @@ void throwsBadRequest_whenCurrentPasswordIsWrong() { // act CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(user, "WrongPass1!", "NewPass123!"); + userService.changePassword(user.getId(), "WrongPass1!", "NewPass123!"); }); // assert @@ -135,7 +131,7 @@ void throwsBadRequest_whenNewPasswordIsSameAsCurrent() { // act CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(user, RAW_PASSWORD, RAW_PASSWORD); + userService.changePassword(user.getId(), RAW_PASSWORD, RAW_PASSWORD); }); // assert @@ -150,7 +146,7 @@ void throwsBadRequest_whenNewPasswordViolatesPolicy() { // act CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(user, RAW_PASSWORD, "short"); + userService.changePassword(user.getId(), RAW_PASSWORD, "short"); }); // assert diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java new file mode 100644 index 000000000..ec8754b12 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java @@ -0,0 +1,111 @@ +package com.loopers.domain.user; + +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 UserNameTest { + + @DisplayName("UserName을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 이름이면, 정상적으로 생성된다.") + @Test + void createsUserName_whenValueIsValid() { + // act + UserName userName = new UserName("홍길동"); + + // assert + assertThat(userName.getValue()).isEqualTo("홍길동"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> new UserName(null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> new UserName(" ")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름을 마스킹할 때, ") + @Nested + class Mask { + + @DisplayName("마지막 글자가 '*'로 대체된다.") + @Test + void masksLastCharacter() { + // arrange + UserName userName = new UserName("홍길동"); + + // act & assert + assertThat(userName.mask()).isEqualTo("홍길*"); + } + + @DisplayName("이름이 한 글자이면, '*'로 대체된다.") + @Test + void masksSingleCharacterName() { + // arrange + UserName userName = new UserName("홍"); + + // act & assert + assertThat(userName.mask()).isEqualTo("*"); + } + + @DisplayName("이름이 두 글자이면, 마지막 글자만 '*'로 대체된다.") + @Test + void masksTwoCharacterName() { + // arrange + UserName userName = new UserName("홍길"); + + // act & assert + assertThat(userName.mask()).isEqualTo("홍*"); + } + } + + @DisplayName("동등성을 비교할 때, ") + @Nested + class Equality { + + @DisplayName("같은 값이면 동일한 객체로 판단한다.") + @Test + void isEqual_whenValuesAreSame() { + // arrange + UserName name1 = new UserName("홍길동"); + UserName name2 = new UserName("홍길동"); + + // assert + assertThat(name1).isEqualTo(name2); + assertThat(name1.hashCode()).isEqualTo(name2.hashCode()); + } + + @DisplayName("다른 값이면 다른 객체로 판단한다.") + @Test + void isNotEqual_whenValuesAreDifferent() { + // arrange + UserName name1 = new UserName("홍길동"); + UserName name2 = new UserName("김철수"); + + // assert + assertThat(name1).isNotEqualTo(name2); + } + } +} 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..9fe804522 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminBrandV1ApiE2ETest.java @@ -0,0 +1,250 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminBrandV1ApiE2ETest { + + private static final String ENDPOINT = "/api-admin/v1/brands"; + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP = "loopers.admin"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminBrandV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LDAP, ADMIN_LDAP); + headers.set("Content-Type", "application/json"); + return headers; + } + + private AdminBrandV1Dto.BrandResponse createBrand(String name) { + AdminBrandV1Dto.CreateRequest request = new AdminBrandV1Dto.CreateRequest(name); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data(); + } + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class Create { + + @DisplayName("올바른 요청이면, 200 OK와 함께 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenValidRequest() { + // arrange + AdminBrandV1Dto.CreateRequest request = new AdminBrandV1Dto.CreateRequest("나이키"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().id()).isNotNull() + ); + } + + @DisplayName("이름이 비어있으면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenNameIsBlank() { + // arrange + AdminBrandV1Dto.CreateRequest request = new AdminBrandV1Dto.CreateRequest(""); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("어드민 인증이 없으면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNoAdminAuth() { + // arrange + AdminBrandV1Dto.CreateRequest request = new AdminBrandV1Dto.CreateRequest("나이키"); + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api-admin/v1/brands") + @Nested + class GetAll { + + @DisplayName("브랜드가 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenBrandsExist() { + // arrange + createBrand("나이키"); + createBrand("아디다스"); + createBrand("뉴발란스"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=2", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2) + ); + } + } + + @DisplayName("GET /api-admin/v1/brands/{brandId}") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드이면, 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenBrandExists() { + // arrange + AdminBrandV1Dto.BrandResponse created = createBrand("나이키"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().createdAt()).isNotNull() + ); + } + + @DisplayName("존재하지 않는 브랜드이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PUT /api-admin/v1/brands/{brandId}") + @Nested + class Update { + + @DisplayName("올바른 요청이면, 수정된 브랜드 정보를 반환한다.") + @Test + void returnsUpdatedBrand_whenValidRequest() { + // arrange + AdminBrandV1Dto.BrandResponse created = createBrand("나이키"); + AdminBrandV1Dto.UpdateRequest request = new AdminBrandV1Dto.UpdateRequest("아디다스"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("아디다스") + ); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{brandId}") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드이면, 200 OK를 반환하고 조회되지 않는다.") + @Test + void deletesAndReturnsSuccess_whenBrandExists() { + // arrange + AdminBrandV1Dto.BrandResponse created = createBrand("나이키"); + + // act + ResponseEntity> deleteResponse = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.DELETE, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert - delete succeeds + assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()); + + // assert - brand is no longer retrievable + ResponseEntity> getResponse = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("존재하지 않는 브랜드이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.DELETE, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} 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..db619f91f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminOrderV1ApiE2ETest.java @@ -0,0 +1,181 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.order.AdminOrderV1Dto; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +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 java.time.LocalDate; +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.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminOrderV1ApiE2ETest { + + private static final String ADMIN_ORDER_ENDPOINT = "/api-admin/v1/orders"; + private static final String ORDER_ENDPOINT = "/api/v1/orders"; + private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String SIGNUP_ENDPOINT = "/api/v1/users"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminOrderV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long productId; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testUser1"); + headers.set("X-Loopers-LoginPw", "Abcd1234!"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private void signupUser() { + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testUser1", "Abcd1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com" + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {}); + } + + @BeforeEach + void setUp() { + signupUser(); + + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + ADMIN_BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long brandId = brandResponse.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest = new AdminProductV1Dto.CreateRequest(brandId, "에어맥스", 129000, 100); + ResponseEntity> productResponse = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId = productResponse.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private OrderV1Dto.OrderDetailResponse createOrder(int quantity) { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(productId, quantity)) + ); + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data(); + } + + @DisplayName("GET /api-admin/v1/orders") + @Nested + class GetAllOrders { + + @DisplayName("주문이 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenOrdersExist() { + createOrder(2); + createOrder(1); + createOrder(3); + + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ORDER_ENDPOINT + "?page=0&size=2", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2), + () -> assertThat(response.getBody().data().content().get(0).userId()).isNotNull() + ); + } + + @DisplayName("어드민 인증이 없으면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNotAdmin() { + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ORDER_ENDPOINT, HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api-admin/v1/orders/{orderId}") + @Nested + class GetOrderDetail { + + @DisplayName("존재하는 주문이면, 주문 상세를 반환한다.") + @Test + void returnsOrderDetail_whenOrderExists() { + OrderV1Dto.OrderDetailResponse created = createOrder(2); + + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ORDER_ENDPOINT + "/" + created.orderId(), HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(created.orderId()), + () -> assertThat(response.getBody().data().userId()).isNotNull(), + () -> assertThat(response.getBody().data().items()).hasSize(1), + () -> assertThat(response.getBody().data().items().get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().items().get(0).brandName()).isEqualTo("나이키") + ); + } + + @DisplayName("존재하지 않는 주문이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenOrderDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ORDER_ENDPOINT + "/999", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} 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..bc6bfc624 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminProductV1ApiE2ETest.java @@ -0,0 +1,271 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminProductV1ApiE2ETest { + + private static final String ENDPOINT = "/api-admin/v1/products"; + private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminProductV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long brandId; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + @BeforeEach + void setUp() { + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + brandId = brandResponse.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private AdminProductV1Dto.ProductResponse createProduct(String name, int price, int stock) { + AdminProductV1Dto.CreateRequest request = new AdminProductV1Dto.CreateRequest(brandId, name, price, stock); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data(); + } + + @DisplayName("POST /api-admin/v1/products") + @Nested + class Create { + + @DisplayName("올바른 요청이면, 200 OK와 함께 상품 정보를 반환한다.") + @Test + void returnsProductInfo_whenValidRequest() { + AdminProductV1Dto.CreateRequest request = new AdminProductV1Dto.CreateRequest(brandId, "에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().price()).isEqualTo(129000), + () -> assertThat(response.getBody().data().stock()).isEqualTo(100), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0) + ); + } + + @DisplayName("존재하지 않는 브랜드이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + AdminProductV1Dto.CreateRequest request = new AdminProductV1Dto.CreateRequest(999L, "에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("이름이 비어있으면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenNameIsBlank() { + AdminProductV1Dto.CreateRequest request = new AdminProductV1Dto.CreateRequest(brandId, "", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api-admin/v1/products") + @Nested + class GetAll { + + @DisplayName("상품이 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenProductsExist() { + createProduct("에어맥스", 129000, 100); + createProduct("에어포스1", 109000, 200); + createProduct("에어조던", 179000, 50); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=2", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2) + ); + } + + @DisplayName("브랜드별 필터링이 동작한다.") + @Test + void filtersByBrandId() { + createProduct("에어맥스", 129000, 100); + + AdminBrandV1Dto.CreateRequest brand2Request = new AdminBrandV1Dto.CreateRequest("아디다스"); + ResponseEntity> brand2Response = testRestTemplate.exchange( + BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brand2Request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long brand2Id = brand2Response.getBody().data().id(); + + AdminProductV1Dto.CreateRequest product2 = new AdminProductV1Dto.CreateRequest(brand2Id, "울트라부스트", 159000, 50); + testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(product2, adminHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?brandId=" + brandId, HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("에어맥스") + ); + } + } + + @DisplayName("GET /api-admin/v1/products/{productId}") + @Nested + class GetById { + + @DisplayName("존재하는 상품이면, 상품 정보를 반환한다.") + @Test + void returnsProductInfo_whenProductExists() { + AdminProductV1Dto.ProductResponse created = createProduct("에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().stock()).isEqualTo(100), + () -> assertThat(response.getBody().data().createdAt()).isNotNull() + ); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PUT /api-admin/v1/products/{productId}") + @Nested + class Update { + + @DisplayName("올바른 요청이면, 수정된 상품 정보를 반환한다.") + @Test + void returnsUpdatedProduct_whenValidRequest() { + AdminProductV1Dto.ProductResponse created = createProduct("에어맥스", 129000, 100); + AdminProductV1Dto.UpdateRequest request = new AdminProductV1Dto.UpdateRequest("에어포스1", 109000, 200); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어포스1"), + () -> assertThat(response.getBody().data().price()).isEqualTo(109000), + () -> assertThat(response.getBody().data().stock()).isEqualTo(200), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(brandId) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{productId}") + @Nested + class Delete { + + @DisplayName("존재하는 상품이면, 200 OK를 반환하고 조회되지 않는다.") + @Test + void deletesAndReturnsSuccess_whenProductExists() { + AdminProductV1Dto.ProductResponse created = createProduct("에어맥스", 129000, 100); + + ResponseEntity> deleteResponse = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.DELETE, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()); + + ResponseEntity> getResponse = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.DELETE, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + 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..4d01b10ed --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java @@ -0,0 +1,113 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.brand.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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/brands"; + private static final String ADMIN_ENDPOINT = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private Long createBrandViaAdmin(String name) { + AdminBrandV1Dto.CreateRequest request = new AdminBrandV1Dto.CreateRequest(name); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + @DisplayName("GET /api/v1/brands/{brandId}") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드이면, 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenBrandExists() { + // arrange + Long brandId = createBrandViaAdmin("나이키"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("존재하지 않는 브랜드이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("인증 없이도 조회할 수 있다.") + @Test + void returnsBrandInfo_withoutAuth() { + // arrange + Long brandId = createBrandViaAdmin("아디다스"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CartV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CartV1ApiE2ETest.java new file mode 100644 index 000000000..f4ab74879 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CartV1ApiE2ETest.java @@ -0,0 +1,304 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.cart.CartV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +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 java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class CartV1ApiE2ETest { + + private static final String CART_ENDPOINT = "/api/v1/cart"; + private static final String CART_ITEMS_ENDPOINT = "/api/v1/cart/items"; + private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String SIGNUP_ENDPOINT = "/api/v1/users"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public CartV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long productId; + private Long productId2; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testUser1"); + headers.set("X-Loopers-LoginPw", "Abcd1234!"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private void signupUser() { + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testUser1", "Abcd1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com" + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {}); + } + + @BeforeEach + void setUp() { + signupUser(); + + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + ADMIN_BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long brandId = brandResponse.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest1 = new AdminProductV1Dto.CreateRequest(brandId, "에어맥스", 129000, 100); + ResponseEntity> productResponse1 = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest1, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId = productResponse1.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest2 = new AdminProductV1Dto.CreateRequest(brandId, "에어포스1", 109000, 200); + ResponseEntity> productResponse2 = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest2, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId2 = productResponse2.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long addToCart(Long productId, int quantity) { + CartV1Dto.AddRequest request = new CartV1Dto.AddRequest(productId, quantity); + testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> cartResponse = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + return cartResponse.getBody().data().items().stream() + .filter(item -> item.productId().equals(productId)) + .findFirst() + .map(CartV1Dto.CartItemResponse::cartItemId) + .orElse(null); + } + + @DisplayName("POST /api/v1/cart/items") + @Nested + class AddToCart { + + @DisplayName("새 상품을 담으면, 200 OK를 반환한다.") + @Test + void returnsSuccess_whenAddingNewProduct() { + CartV1Dto.AddRequest request = new CartV1Dto.AddRequest(productId, 2); + + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("이미 담긴 상품을 다시 담으면, 수량이 합산된다.") + @Test + void addsQuantity_whenProductAlreadyInCart() { + CartV1Dto.AddRequest request1 = new CartV1Dto.AddRequest(productId, 2); + testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request1, authHeaders()), + new ParameterizedTypeReference>() {} + ); + + CartV1Dto.AddRequest request2 = new CartV1Dto.AddRequest(productId, 3); + testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request2, authHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> cartResponse = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(cartResponse.getBody().data().items()).hasSize(1), + () -> assertThat(cartResponse.getBody().data().items().get(0).quantity()).isEqualTo(5) + ); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + CartV1Dto.AddRequest request = new CartV1Dto.AddRequest(999L, 2); + + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("인증되지 않은 사용자이면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNotAuthenticated() { + CartV1Dto.AddRequest request = new CartV1Dto.AddRequest(productId, 2); + + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api/v1/cart") + @Nested + class GetMyCart { + + @DisplayName("장바구니에 항목이 있으면, 상품/브랜드 정보와 함께 목록을 반환한다.") + @Test + void returnsCartWithProductInfo_whenItemsExist() { + addToCart(productId, 2); + addToCart(productId2, 1); + + ResponseEntity> response = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().items()).hasSize(2), + () -> assertThat(response.getBody().data().items().get(0).brandName()).isEqualTo("나이키") + ); + } + + @DisplayName("장바구니가 비어있으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenCartIsEmpty() { + ResponseEntity> response = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + } + + @DisplayName("PUT /api/v1/cart/items/{cartItemId}") + @Nested + class UpdateQuantity { + + @DisplayName("올바른 수량이면, 200 OK를 반환한다.") + @Test + void returnsSuccess_whenValidQuantity() { + Long cartItemId = addToCart(productId, 2); + + CartV1Dto.UpdateQuantityRequest request = new CartV1Dto.UpdateQuantityRequest(5); + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT + "/" + cartItemId, HttpMethod.PUT, + new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + + ResponseEntity> cartResponse = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(cartResponse.getBody().data().items().get(0).quantity()).isEqualTo(5); + } + + @DisplayName("존재하지 않는 항목이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenItemDoesNotExist() { + CartV1Dto.UpdateQuantityRequest request = new CartV1Dto.UpdateQuantityRequest(5); + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT + "/999", HttpMethod.PUT, + new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("DELETE /api/v1/cart/items/{cartItemId}") + @Nested + class RemoveItem { + + @DisplayName("존재하는 항목이면, 200 OK를 반환하고 삭제된다.") + @Test + void returnsSuccess_whenItemExists() { + Long cartItemId = addToCart(productId, 2); + + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT + "/" + cartItemId, HttpMethod.DELETE, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + + ResponseEntity> cartResponse = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(cartResponse.getBody().data().items()).isEmpty(); + } + + @DisplayName("존재하지 않는 항목이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenItemDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT + "/999", HttpMethod.DELETE, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + 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..34d4d2085 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java @@ -0,0 +1,237 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.like.LikeV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +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 java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + private static final String LIKE_ENDPOINT = "/api/v1/products/{productId}/likes"; + private static final String MY_LIKES_ENDPOINT = "/api/v1/likes"; + private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String SIGNUP_ENDPOINT = "/api/v1/users"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public LikeV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long productId; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testUser1"); + headers.set("X-Loopers-LoginPw", "Abcd1234!"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private void signupUser() { + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testUser1", "Abcd1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com" + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {}); + } + + @BeforeEach + void setUp() { + signupUser(); + + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + ADMIN_BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long brandId = brandResponse.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest = new AdminProductV1Dto.CreateRequest(brandId, "에어맥스", 129000, 100); + ResponseEntity> productResponse = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId = productResponse.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private String likeUrl(Long productId) { + return "/api/v1/products/" + productId + "/likes"; + } + + @DisplayName("POST /api/v1/products/{productId}/likes") + @Nested + class LikeProduct { + + @DisplayName("인증된 사용자가 좋아요하면, 200 OK를 반환한다.") + @Test + void returnsSuccess_whenAuthenticated() { + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("이미 좋아요한 상품이면, 409 CONFLICT를 반환한다.") + @Test + void returnsConflict_whenAlreadyLiked() { + testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(999L), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("인증되지 않은 사용자이면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNotAuthenticated() { + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}/likes") + @Nested + class UnlikeProduct { + + @DisplayName("좋아요가 존재하면, 200 OK를 반환한다.") + @Test + void returnsSuccess_whenLikeExists() { + testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(productId), HttpMethod.DELETE, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("좋아요가 존재하지 않으면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenLikeDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(productId), HttpMethod.DELETE, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api/v1/likes") + @Nested + class GetMyLikes { + + @DisplayName("좋아요한 상품이 있으면, 목록을 반환한다.") + @Test + void returnsLikes_whenLikesExist() { + testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> response = testRestTemplate.exchange( + MY_LIKES_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().likes()).hasSize(1), + () -> assertThat(response.getBody().data().likes().get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().likes().get(0).brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().likes().get(0).price()).isEqualTo(129000) + ); + } + + @DisplayName("좋아요한 상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoLikes() { + ResponseEntity> response = testRestTemplate.exchange( + MY_LIKES_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().likes()).isEmpty() + ); + } + + @DisplayName("인증되지 않은 사용자이면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNotAuthenticated() { + ResponseEntity> response = testRestTemplate.exchange( + MY_LIKES_ENDPOINT, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } +} 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..58af71b91 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -0,0 +1,354 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.cart.CartV1Dto; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +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 java.time.LocalDate; +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.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private static final String ORDER_ENDPOINT = "/api/v1/orders"; + private static final String CART_ITEMS_ENDPOINT = "/api/v1/cart/items"; + private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String SIGNUP_ENDPOINT = "/api/v1/users"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public OrderV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long productId; + private Long productId2; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testUser1"); + headers.set("X-Loopers-LoginPw", "Abcd1234!"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private void signupUser() { + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testUser1", "Abcd1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com" + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {}); + } + + @BeforeEach + void setUp() { + signupUser(); + + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + ADMIN_BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long brandId = brandResponse.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest1 = new AdminProductV1Dto.CreateRequest(brandId, "에어맥스", 129000, 100); + ResponseEntity> productResponse1 = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest1, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId = productResponse1.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest2 = new AdminProductV1Dto.CreateRequest(brandId, "에어포스1", 109000, 200); + ResponseEntity> productResponse2 = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest2, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId2 = productResponse2.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private OrderV1Dto.OrderDetailResponse createOrder(Long pId, int quantity) { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(pId, quantity)) + ); + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data(); + } + + @DisplayName("POST /api/v1/orders") + @Nested + class CreateOrder { + + @DisplayName("올바른 주문 요청이면, 주문 상세 정보를 반환한다.") + @Test + void returnsOrderDetail_whenValidRequest() { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of( + new OrderV1Dto.OrderItemRequest(productId, 2), + new OrderV1Dto.OrderItemRequest(productId2, 1) + ) + ); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orderId()).isNotNull(), + () -> assertThat(response.getBody().data().totalPrice()).isEqualTo(129000 * 2 + 109000), + () -> assertThat(response.getBody().data().status()).isEqualTo("ORDERED"), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + } + + @DisplayName("주문 후 상품 재고가 차감된다.") + @Test + void deductsStock_whenOrderSucceeds() { + createOrder(productId, 3); + + ResponseEntity> productResponse = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT + "/" + productId, HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(productResponse.getBody().data().stock()).isEqualTo(97); + } + + @DisplayName("중복된 상품이 포함되면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenDuplicateProducts() { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of( + new OrderV1Dto.OrderItemRequest(productId, 2), + new OrderV1Dto.OrderItemRequest(productId, 3) + ) + ); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("재고가 부족하면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenStockInsufficient() { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(productId, 999)) + ); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(999L, 1)) + ); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("인증되지 않은 사용자이면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNotAuthenticated() { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(productId, 1)) + ); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("POST /api/v1/orders/cart") + @Nested + class CreateOrderFromCart { + + @DisplayName("장바구니에 항목이 있으면, 주문이 생성되고 장바구니가 비워진다.") + @Test + void createsOrderAndClearsCart_whenCartHasItems() { + // Add items to cart + CartV1Dto.AddRequest cartRequest1 = new CartV1Dto.AddRequest(productId, 2); + testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(cartRequest1, authHeaders()), + new ParameterizedTypeReference>() {} + ); + CartV1Dto.AddRequest cartRequest2 = new CartV1Dto.AddRequest(productId2, 1); + testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(cartRequest2, authHeaders()), + new ParameterizedTypeReference>() {} + ); + + // Create order from cart + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "/cart", HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalPrice()).isEqualTo(129000 * 2 + 109000), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + + // Verify cart is empty + ResponseEntity> cartResponse = testRestTemplate.exchange( + "/api/v1/cart", HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(cartResponse.getBody().data().items()).isEmpty(); + } + + @DisplayName("장바구니가 비어있으면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenCartIsEmpty() { + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "/cart", HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/orders") + @Nested + class GetMyOrders { + + @DisplayName("기간 내 주문이 있으면, 주문 목록을 반환한다.") + @Test + void returnsOrders_whenOrdersExistInRange() { + createOrder(productId, 2); + createOrder(productId2, 1); + + String today = LocalDate.now().toString(); + String tomorrow = LocalDate.now().plusDays(1).toString(); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "?startAt=" + today + "&endAt=" + tomorrow, + HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2) + ); + } + + @DisplayName("기간 외 주문이면, 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoOrdersInRange() { + createOrder(productId, 2); + + String futureStart = LocalDate.now().plusDays(10).toString(); + String futureEnd = LocalDate.now().plusDays(20).toString(); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "?startAt=" + futureStart + "&endAt=" + futureEnd, + HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).isEmpty() + ); + } + } + + @DisplayName("GET /api/v1/orders/{orderId}") + @Nested + class GetMyOrderDetail { + + @DisplayName("본인의 주문이면, 주문 상세를 반환한다.") + @Test + void returnsOrderDetail_whenOwner() { + OrderV1Dto.OrderDetailResponse created = createOrder(productId, 2); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "/" + created.orderId(), HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(created.orderId()), + () -> assertThat(response.getBody().data().items()).hasSize(1), + () -> assertThat(response.getBody().data().items().get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().items().get(0).brandName()).isEqualTo("나이키") + ); + } + + @DisplayName("존재하지 않는 주문이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenOrderDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "/999", HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + 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..36df1b127 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -0,0 +1,174 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +import com.loopers.interfaces.api.product.ProductV1Dto; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/products"; + private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long brandId; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + @BeforeEach + void setUp() { + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + ADMIN_BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + brandId = brandResponse.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long createProductViaAdmin(String name, int price, int stock) { + AdminProductV1Dto.CreateRequest request = new AdminProductV1Dto.CreateRequest(brandId, name, price, stock); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + @DisplayName("GET /api/v1/products") + @Nested + class GetAll { + + @DisplayName("상품이 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenProductsExist() { + createProductViaAdmin("에어맥스", 129000, 100); + createProductViaAdmin("에어포스1", 109000, 200); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2) + ); + } + + @DisplayName("인증 없이도 조회할 수 있다.") + @Test + void returnsProducts_withoutAuth() { + createProductViaAdmin("에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("브랜드별 필터링이 동작한다.") + @Test + void filtersByBrandId() { + createProductViaAdmin("에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?brandId=" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).brandId()).isEqualTo(brandId) + ); + } + } + + @DisplayName("GET /api/v1/products/{productId}") + @Nested + class GetById { + + @DisplayName("존재하는 상품이면, 상품 정보를 반환한다 (stock 미노출).") + @Test + void returnsProductInfo_whenProductExists() { + Long productId = createProductViaAdmin("에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().price()).isEqualTo(129000), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0) + ); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("인증 없이도 조회할 수 있다.") + @Test + void returnsProductInfo_withoutAuth() { + Long productId = createProductViaAdmin("에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolverTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolverTest.java new file mode 100644 index 000000000..48720134d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolverTest.java @@ -0,0 +1,123 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.application.user.UserApplicationService; +import com.loopers.domain.user.User; +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.core.MethodParameter; +import org.springframework.web.context.request.NativeWebRequest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AuthUserArgumentResolverTest { + + private UserApplicationService userApplicationService; + private AuthUserArgumentResolver resolver; + + @BeforeEach + void setUp() { + userApplicationService = mock(UserApplicationService.class); + resolver = new AuthUserArgumentResolver(userApplicationService); + } + + @DisplayName("supportsParameter 검증할 때, ") + @Nested + class SupportsParameter { + + @DisplayName("@AuthUser와 AuthenticatedUser 타입이면, true를 반환한다.") + @Test + void returnsTrue_whenAuthUserAnnotationWithAuthenticatedUserType() { + MethodParameter parameter = mock(MethodParameter.class); + when(parameter.hasParameterAnnotation(AuthUser.class)).thenReturn(true); + when(parameter.getParameterType()).thenReturn((Class) AuthenticatedUser.class); + + assertThat(resolver.supportsParameter(parameter)).isTrue(); + } + + @DisplayName("@AuthUser와 User 타입이면, false를 반환한다.") + @Test + void returnsFalse_whenAuthUserAnnotationWithUserType() { + MethodParameter parameter = mock(MethodParameter.class); + when(parameter.hasParameterAnnotation(AuthUser.class)).thenReturn(true); + when(parameter.getParameterType()).thenReturn((Class) User.class); + + assertThat(resolver.supportsParameter(parameter)).isFalse(); + } + + @DisplayName("@AuthUser 어노테이션이 없으면, false를 반환한다.") + @Test + void returnsFalse_whenNoAuthUserAnnotation() { + MethodParameter parameter = mock(MethodParameter.class); + when(parameter.hasParameterAnnotation(AuthUser.class)).thenReturn(false); + when(parameter.getParameterType()).thenReturn((Class) AuthenticatedUser.class); + + assertThat(resolver.supportsParameter(parameter)).isFalse(); + } + } + + @DisplayName("인증 헤더를 검증할 때, ") + @Nested + class ResolveArgument { + + @DisplayName("로그인 ID 헤더가 누락되면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenLoginIdHeaderMissing() { + NativeWebRequest webRequest = mock(NativeWebRequest.class); + when(webRequest.getHeader("X-Loopers-LoginId")).thenReturn(null); + when(webRequest.getHeader("X-Loopers-LoginPw")).thenReturn("password"); + + CoreException result = assertThrows(CoreException.class, + () -> resolver.resolveArgument(null, null, webRequest, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("로그인 ID 헤더가 빈 문자열이면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenLoginIdHeaderBlank() { + NativeWebRequest webRequest = mock(NativeWebRequest.class); + when(webRequest.getHeader("X-Loopers-LoginId")).thenReturn(" "); + when(webRequest.getHeader("X-Loopers-LoginPw")).thenReturn("password"); + + CoreException result = assertThrows(CoreException.class, + () -> resolver.resolveArgument(null, null, webRequest, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("비밀번호 헤더가 누락되면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenPasswordHeaderMissing() { + NativeWebRequest webRequest = mock(NativeWebRequest.class); + when(webRequest.getHeader("X-Loopers-LoginId")).thenReturn("user1"); + when(webRequest.getHeader("X-Loopers-LoginPw")).thenReturn(null); + + CoreException result = assertThrows(CoreException.class, + () -> resolver.resolveArgument(null, null, webRequest, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("정상 헤더이면, AuthenticatedUser를 반환한다.") + @Test + void returnsAuthenticatedUser_whenValidHeaders() { + NativeWebRequest webRequest = mock(NativeWebRequest.class); + when(webRequest.getHeader("X-Loopers-LoginId")).thenReturn("user1"); + when(webRequest.getHeader("X-Loopers-LoginPw")).thenReturn("password"); + + User user = new User("user1", "encryptedPw", "홍길동", LocalDate.of(1990, 1, 1), "test@test.com"); + // User entity's ID is set by JPA, so we use reflection or trust the flow + when(userApplicationService.authenticate("user1", "password")).thenReturn(user); + + AuthenticatedUser result = resolver.resolveArgument(null, null, webRequest, null); + + assertThat(result.loginId()).isEqualTo("user1"); + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 39180cd26..f67870e8a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,7 +48,7 @@ subprojects { dependencies { // Web - runtimeOnly("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-validation") // Spring implementation("org.springframework.boot:spring-boot-starter") // Serialize @@ -64,6 +64,7 @@ subprojects { testImplementation("com.ninja-squad:springmockk:${project.properties["springMockkVersion"]}") testImplementation("org.mockito:mockito-core:${project.properties["mockitoVersion"]}") testImplementation("org.instancio:instancio-junit:${project.properties["instancioJUnitVersion"]}") + testImplementation("com.tngtech.archunit:archunit-junit5:${project.properties["archunitVersion"]}") // Testcontainers testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers:1.21.4") diff --git a/gradle.properties b/gradle.properties index 5ae37ac99..5b4e94b0c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,5 +15,6 @@ springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 instancioJUnitVersion=5.0.2 +archunitVersion=1.4.1 slackAppenderVersion=1.6.1 kotlin.daemon.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m