diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md new file mode 100644 index 000000000..d500ef3de --- /dev/null +++ b/.docs/design/01-requirements.md @@ -0,0 +1,137 @@ +# **요구사항 명세서** + +## **1. 상품 목록 조회** + +### 1.1. 유저 시나리오 +> "사용자가 상품을 둘러보기 위해 사이트에 접속했다. 그는 어떤 제품이 인기가 많은지 보기 위해 상품 목록을 조회한다." + +### 1.2. 핵심 기능 정의 +- **F-1:** 상품 목록을 조회할 수 있다. +- **F-2:** 상품 목록을 '좋아요순'으로 정렬할 수 있다. +- **F-3:** 상품 목록을 '최신순', '가격순' 등 다른 기준으로도 정렬할 수 있다. +- **F-4:** 상품 목록을 페이지 단위로 나누어 볼 수 있다. + +### 1.3. 유스케이스 흐름 +* **Main Flow (기본 조회):** + 1. 사용자가 상품 목록 페이지에 진입한다. + 2. 최신순으로 첫 번째 페이지의 상품 목록을 반환한다. + 3. 화면에 상품 목록이 표시된다. +* **Alternate Flow (정렬 변경):** + 1. 사용자가 '인기순' 정렬 버튼을 클릭한다. + 2. 인기순으로 상품 목록을 다시 조회하여 반환한다. + 3. 화면에 인기순으로 정렬된 목록이 표시된다. +* **Alternate Flow (페이징):** + 1. 사용자가 다른 페이지를 클릭한다. + 2. 다른 페이지의 상품 목록을 조회하여 반환한다. + 3. 화면에 다음 상품 목록이 이어서 표시된다. +* **Exception Flow (결과 없음):** + 1. 사용자가 특정 필터를 적용했으나, 해당하는 상품이 하나도 없다. + 2. "조회된 상품이 없습니다."라는 메시지를 반환한다. + +--- + +## **2. 상품 상세 조회** + +### 2.1. 유저 시나리오 +> "사용자가 마음에 드는 상품이 있어서 해당 상품을 클릭했다. 그는 해당 상품의 상세 정보를 확인하고 싶어 한다." + +### 2.2. 핵심 기능 정의 +- **F-1:** 상품 상세정보를 조회할 수 있다. +- **F-2:** 상품의 총 좋아요 수와 나의 좋아요 여부를 조회할 수 있다. +- **F-3:** 구매할 상품의 수량을 선택할 수 있다. + +### 2.3. 유스케이스 흐름 +* **Main Flow (상세 조회):** + 1. 사용자가 상품 목록에서 특정 상품을 클릭한다. + 2. 해당 상품의 상세 정보(이름, 가격, 설명, 총 좋아요 수, 나의 좋아요 여부 등)를 조회하여 반환한다. + 3. 화면에 상품 상세 정보가 표시되고, 수량은 '1'로 기본 설정된다. +* **Alternate Flow (수량 변경):** + 1. 사용자가 '+' 버튼을 눌러 수량을 '3'으로 변경한다. + 2. 화면에 수량이 '3'으로 갱신된다. +* **Exception Flow (상품 없음):** + 1. 사용자가 존재하지 않는 상품 ID의 URL로 직접 접근한다. + 2. 상품을 찾을 수 없으므로, "상품을 찾을 수 없습니다."라는 오류를 반환한다. + +--- + +## **3. 브랜드별 상품 조회** + +### 3.1. 유저 시나리오 +> "사용자가 마음에 드는 특정 브랜드를 찾고 있다. 그는 해당 브랜드에서 나오는 상품만 모아서 조회하고 싶어 한다." + +### 3.2. 핵심 기능 정의 +- **F-1:** 특정 브랜드에 속한 상품만 필터링하여 조회할 수 있다. +- **F-2:** 상품 목록을 '인기순'으로 정렬할 수 있다. +- **F-3:** 상품 목록을 '최신순', '가격순' 등 다른 기준으로도 정렬할 수 있다. +- **F-4:** 상품 목록을 페이지 단위로 나누어 볼 수 있다. + +### 3.3. 유스케이스 흐름 +* **Main Flow (브랜드별 조회):** + 1. 사용자가 'A 브랜드'를 조회한다. + 2. 해당 브랜드 상품 목록을 반환한다. + 3. 화면에 'A 브랜드'의 상품 목록만 표시된다. +* **Exception Flow (브랜드 없음):** + 1. 사용자가 존재하지 않는 브랜드 ID의 URL로 직접 접근한다. + 2. "브랜드를 찾을 수 없습니다."라는 오류(404 Not Found)를 반환한다. + +--- + +## **4. 상품 좋아요 (등록/취소)** + +### 4.1. 유저 시나리오 +> (등록) "사용자가 마음에 드는 상품을 발견했다. 그는 나중에 마음에 드는 상품을 모아보기 위해 '좋아요'를 누른다." +> (취소) "사용자가 이전에 '좋아요' 했던 상품이 더 이상 마음에 들지 않아 '좋아요'를 취소한다." + +### 4.2. 핵심 기능 정의 +- **F-1:** 상품에 대해 '좋아요'를 등록할 수 있다. +- **F-2:** 이미 '좋아요'가 등록된 상품의 '좋아요'를 취소할 수 있다. + +### 4.3. 유스케이스 흐름 +* **Main Flow (좋아요 등록):** + 1. 사용자가 좋아요 아이콘을 클릭한다. + 2. 좋아요 여부를 판단한다. + 3. 해당 상품이 좋아요가 안되어있으면 좋아요 등록이 된다. +* **Alternate Flow (좋아요 취소):** + 1. 사용자가 좋아요 아이콘을 클릭한다. + 2. 좋아요 여부를 판단한다. + 3. 해당 상품이 좋아요가 되어있으면 좋아요가 취소된다. +* **Exception Flow (비회원):** + 1. 비로그인 사용자가 좋아요 아이콘을 클릭한다. + 2. "로그인이 필요한 기능입니다."라는 오류 메시지를 반환한다. + +--- + +## **5. 주문 생성 (결제)** + +### 5.1. 유저 시나리오 +> "사용자가 특정 상품을 구매하고 싶어, 해당 상품을 결제한다." + +### 5.2. 핵심 기능 정의 +- **F-1:** 사용자가 선택한 상품을 구매할 수 있다. +- **F-2:** 사용자의 포인트가 충분할 경우 결제할 수 있으며, 결제 시 포인트가 차감된다. +- **F-3:** 상품의 재고가 충분할 경우 결제할 수 있으며, 결제 시 재고가 차감된다. +- **F-4:** 결제 성공 시 주문 정보가 외부 시스템으로 전송된다. + +### 5.3. 유스케이스 흐름 +* **Main Flow (주문 성공):** + 1. 로그인한 사용자가 상품과 수량을 선택하고 '결제하기'를 요청한다. + 2. 상품 재고가 1개 이상인지 확인한다. + 3. 사용자 포인트가 총 결제 금액 이상인지 확인한다. + 4. 상품 재고를 1 차감한다. + 5. 사용자 포인트를 차감한다. + 6. '주문' 및 '주문 항목'을 저장한다. + 7. 주문 정보를 외부 시스템으로 전송한다. + 8. 사용자에게 "주문 완료" 응답을 반환한다. +* **Alternate Flow (여러 수량 구매):** + 1. 사용자가 수량을 '3'으로 선택하고 '결제하기'를 요청한다. + 2. 재고 및 포인트를 수량 기준으로 검증, 차감, 저장한다. + 3. 주문에 성공한다. +* **Exception Flow (재고 부족):** + 1. 시스템이 재고를 확인했으나, 요청 수량보다 재고가 부족하다. + 2. "재고가 부족하여 주문할 수 없습니다."라는 오류 메시지를 반환한다. +* **Exception Flow (포인트 부족):** + 1. 시스템이 사용자의 포인트를 확인했으나, 총 결제 금액보다 포인트가 부족하다. + 2. "포인트가 부족합니다."라는 오류 메시지를 반환한다. +* **Exception Flow (비회원):** + 1. 비로그인 사용자가 '결제하기'를 요청한다. + 2. "로그인이 필요한 기능입니다."라는 오류 메시지를 반환한다. \ No newline at end of file diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..bd6d61447 --- /dev/null +++ b/.docs/design/02-sequence-diagrams.md @@ -0,0 +1,67 @@ + +### 1. 상품 목록/상세 조회 및 브랜드 조회 +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductReader + + %% 상품 목록 조회 %% + User->>ProductController: GET /api/v1/products?sort=likes_desc + ProductController->>ProductReader: getProducts(sort) + ProductReader-->>ProductController: productList + ProductController-->>User: productList + + %% 상품 상세 조회 %% + User->>ProductController: GET /api/v1/products/{productId} + ProductController->>ProductReader: getProduct(productId) + ProductReader-->>ProductController: product + ProductController-->>User: product + + participant BrandController + participant BrandReader + + %% 브랜드 조회 %% + User->>BrandController: GET /api/v1/brands/{brandId} + BrandController->>BrandReader: getBrand({brandId}) + BrandReader-->>BrandController: brand + BrandController-->>User: brand +``` +### 2. 주문 생성 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderService + participant ProductReader + participant PointReader + participant ProductService + participant PointService + participant OrderRepository + + User->>OrderController: POST /api/v1/orders (body: {productId, quantity}) + OrderController->>OrderService: createOrder(userId, {productId, quantity}) + + %% 조회 및 검증 (서버가 가격/포인트 확인) %% + OrderService->>ProductReader: getProduct({productId}) + ProductReader -->>OrderService: product(현재가격, 재고) + + OrderService->>PointReader: getPoint(userId) + PointReader-->>OrderService: point (현재 잔여 포인트) + + %% 서버가 totalPrice를 직접 계산하는 로직 명시 %% + Note right of OrderService: 3. 서버가 totalPrice를 직접 계산
(product.getPrice() * quantity) + + %% 재고 및 포인트 차감 (계산된 totalPrice 사용) %% + critical Transaction Block + OrderService ->>ProductService: decreaseStock(productId, quantity) + OrderService ->>PointService: deductPoint(calculatedTotalPrice) + OrderService->> OrderRepository: save(new Order(..., calculatedTotalPrice)) + OrderRepository-->>OrderService: orderInfo + end + + %% 응답 %% + OrderService -->> OrderController:orderInfo + OrderController-->> User:orderInfo + ``` \ No newline at end of file diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md new file mode 100644 index 000000000..d72b631aa --- /dev/null +++ b/.docs/design/03-class-diagram.md @@ -0,0 +1,50 @@ +classDiagram + class User { + Long id + String name + int point + } + class Product { + Long id + String name + Price price %% int -> Price (VO) + Quantity quantity %% int -> Quantity (VO) + } + class Brand { + Long id + String name + } + class Like { + User user + Product product + %% boolean liked 제거 + } + class Order { + Long id + User user + int totalPrice + Timestamp created_at + } + class OrderItem { + Order order + Product product + int quantity + int orderPrice + } + + %% --- VO 정의 --- + class Price { <> } + class Quantity { <> } + + %% --- 관계 정의 --- + Product --> Brand : (상품은 브랜드를 가짐) + Product --> Price + Product --> Quantity + + Order --> User : (주문은 유저를 가짐) + + OrderItem --> Order : (주문 항목은 주문에 속함) + OrderItem --> Product : (주문 항목은 상품을 가짐) + + Like --> User : (좋아요는 유저를 가짐) + Like --> Product : (좋아요는 상품을 가짐) \ No newline at end of file diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md new file mode 100644 index 000000000..59f99fff5 --- /dev/null +++ b/.docs/design/04-erd.md @@ -0,0 +1,47 @@ +erDiagram + users { + %% BaseEntity가 id, created_at 등을 제공합니다. + varchar name + } + points { + bigint id PK + bigint user_id FK + int point + } + products { + %% BaseEntity가 id, created_at 등을 제공합니다. + varchar name + int price + int stock_quantity + bigint brand_id FK + } + brands { + %% BaseEntity가 id, created_at 등을 제공합니다. + varchar name + } + likes { + bigint user_id PK, FK + bigint product_id PK, FK + } + orders { + %% BaseEntity가 id, created_at 등을 제공합니다. + bigint user_id FK + int total_price + %% Timestamp created_at 제거 + } + orderitems { + %% BaseEntity가 id, created_at 등을 제공합니다. + bigint order_id FK + bigint product_id FK + int quantity + int order_price + } + + %% --- 관계 정의 (1:N) --- + users ||--o{ likes : "likes" + users ||--o{ orders : "places" + users ||--o{ points : "has" + products ||--o{ likes : "is_liked" + products ||--o{ orderitems : "is_in" + brands ||--o{ products : "has" + orders ||--o{ orderitems : "contains" \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..225b57ef7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,93 @@ +package com.loopers.application.like; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import com.loopers.domain.like.LikeService; +import com.loopers.domain.user.UserService; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.user.UserId; +import java.util.List; +import java.util.Map; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + + private final UserService userService; + + private final ProductService productService; + + @Transactional + public void toggleLike(UserId userId, Long productId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다."); + } + ProductModel product = productService.getProduct(productId); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + likeService.toggleLike(user, product); + } + + @Transactional + public void addLike(UserId userId, Long productId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다."); + } + ProductModel product = productService.getProduct(productId); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + likeService.addLike(user, product); + } + + @Transactional + public void removeLike(UserId userId, Long productId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다."); + } + ProductModel product = productService.getProduct(productId); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + likeService.removeLike(user, product); + } + + @Transactional(readOnly = true) + public boolean isLiked(UserId userId, Long productId) { + UserModel user = userService.getUser(userId); + ProductModel product = productService.getProduct(productId); + return likeService.isLiked(user, product); + } + + @Transactional(readOnly = true) + public List getLikedProducts(UserId userId) { + UserModel user = userService.getUser(userId); + return likeService.getLikedProducts(user); + } + + @Transactional(readOnly = true) + public long getLikeCount(Long productId) { + ProductModel product = productService.getProduct(productId); + return likeService.getLikeCount(product); + } + + @Transactional(readOnly = true) + public Map getLikeCounts(List productIds) { + + List products = productService.getProductsByIds(productIds); + + return likeService.getLikeCounts(products); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java new file mode 100644 index 000000000..319ea330f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,13 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; + +public record LikeInfo(Long id, Long userId, Long productId) { + public static LikeInfo from(LikeModel model) { + return new LikeInfo( + model.getId(), + model.getUser().getId(), + model.getProduct().getId() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..04f24f1fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,55 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import com.loopers.domain.user.UserId; +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.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final UserService userService; + + @Transactional(readOnly = true) + public OrderInfo getOrder(Long id) { + OrderModel order = orderService.getOrder(id); + if (order == null) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + return OrderInfo.from(order); + } + + @Transactional(readOnly = true) + public List getUserOrders(UserId userId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다."); + } + List orders = orderService.getUserOrders(user); + return orders.stream() + .map(OrderInfo::from) + .collect(Collectors.toList()); + } + + @Transactional + public OrderInfo createOrder(UserId userId, List items) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다."); + } + + OrderModel order = orderService.createOrder(user, items); + return OrderInfo.from(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..7d039bbb8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,24 @@ +package com.loopers.application.order; + +import com.loopers.domain.common.Money; +import java.util.List; +import com.loopers.domain.order.OrderModel; +import com.loopers.application.order.OrderItemInfo; + +public record OrderInfo(Long id, Long userId, Money totalPrice, List orderItems) { + public static OrderInfo from(OrderModel order) { + List items = order.getOrderItems().stream() + .map(item -> new OrderItemInfo( + item.getProduct().getId(), + item.getQuantity().quantity(), + item.getOrderPrice() + )) + .toList(); + return new OrderInfo( + order.getId(), + order.getUser().getId(), + order.getTotalPrice(), + items + ); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..8897a01dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,15 @@ +package com.loopers.application.order; + +import com.loopers.domain.common.Money; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.application.order.OrderItemInfo; + +public record OrderItemInfo(Long productId, Integer quantity, Money orderPrice) { + public static OrderItemInfo from(OrderItemModel item) { + return new OrderItemInfo( + item.getProduct().getId(), + item.getQuantity().quantity(), + item.getOrderPrice() + ); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java new file mode 100644 index 000000000..75b33104d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -0,0 +1,46 @@ +package com.loopers.application.point; + +import com.loopers.domain.point.PointModel; +import com.loopers.domain.point.PointService; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import com.loopers.domain.user.UserId; +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class PointFacade { + private final PointService pointService; + private final UserService userService; + + public PointInfo getPoint(UserId userId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); + } + PointModel pointModel = new PointModel(user, new Money(0)); + PointModel point = pointService.findPoint(pointModel); + + if (point == null) { + throw new CoreException(ErrorType.NOT_FOUND, "포인트 정보가 없습니다."); + } + + return PointInfo.from(point); + } + + public PointInfo chargePoint(UserId userId, Money point) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); + } + PointModel pointModel = new PointModel(user, point); + pointService.charge(pointModel); + + PointModel charged = pointService.findPoint(new PointModel(user, point)); + return PointInfo.from(charged); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java new file mode 100644 index 000000000..5e14d764f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java @@ -0,0 +1,14 @@ +package com.loopers.application.point; + +import com.loopers.domain.point.PointModel; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.common.Money; + +public record PointInfo(Long id, UserModel user, Money point) { + public static PointInfo from(PointModel model) { + return new PointInfo(model.getId(), model.getUser(), model.getPoint()); + } + public Money getPoint() { + return point; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..fb6084268 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,43 @@ +package com.loopers.application.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.common.Quantity; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + + // 상품 다건 조회 - 페이징 지원 + public Page getProducts(Pageable pageable, String sort, String brandName) { + Page productPage = productService.getProducts(pageable, sort, brandName); + return productPage.map(ProductInfo::from); + } + + // 상품 단건 조회 + public ProductInfo getProduct(Long id){ + ProductModel product = productService.getProduct(id); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다."); + } + return ProductInfo.from(product); + } + + // 상품 재고 조회 + public Quantity getQuantity(Long id) { + return productService.getQuantity(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다.")); + } + + public void updateQuantity(Long id, Quantity quantity) { + productService.updateQuantity(id, quantity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..714742604 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,17 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.ProductModel; + +public record ProductInfo(Long id, String name, Brand brand, Money price, Long likeCount) { + public static ProductInfo from(ProductModel model) { + return new ProductInfo( + model.getId(), + model.getName(), + model.getBrand(), + model.getPrice(), + model.getLikeCount() + ); + } +} 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 new file mode 100644 index 000000000..17c59c944 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,32 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.Email; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + private final UserService userService; + + public UserInfo signup(String userId, String email, String gender, String birthDate) { + UserModel userModel = new UserModel(new UserId(userId), new Email(email), new Gender(gender), new BirthDate(birthDate)); + UserModel savedUser = userService.signUp(userModel); + return UserInfo.from(savedUser); + } + + public UserInfo getUser(UserId userId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); + } + return UserInfo.from(user); + } +} 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 new file mode 100644 index 000000000..3821e5395 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,14 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.UserModel; + +public record UserInfo(String userId, String email, String birthDate, String gender) { + public static UserInfo from(UserModel model) { + return new UserInfo( + model.getUserId().userId(), + model.getEmail().email(), + model.getBirthDate().birthDate(), + model.getGender().gender() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java new file mode 100644 index 000000000..67887a971 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -0,0 +1,37 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public class Money { + private long value; + + protected Money() { + } + + public Money(long value) { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + this.value = value; + } + + public long value() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Money money = (Money) o; + return value == money.value; + } + + @Override + public int hashCode() { + return Long.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java new file mode 100644 index 000000000..a701e72e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java @@ -0,0 +1,37 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public class Quantity { + private int quantity; + + protected Quantity() { + } + + public Quantity(int quantity) { + if (quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 0 이상이어야 합니다."); + } + this.quantity = quantity; + } + + public int quantity() { + return quantity; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Quantity quantity1 = (Quantity) o; + return quantity == quantity1.quantity; + } + + @Override + public int hashCode() { + return Integer.hashCode(quantity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java new file mode 100644 index 000000000..c3f000741 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -0,0 +1,43 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table( + name = "like", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"}) +) +public class LikeModel extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "user_id") + private UserModel user; + @ManyToOne + @JoinColumn(name = "product_id") + private ProductModel product; + + public LikeModel() { + } + + public LikeModel(UserModel user, ProductModel product) { + this.user = user; + this.product = product; + } + + public UserModel getUser() { + return user; + } + + public ProductModel getProduct() { + return product; + } + +} + 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..f4d8f074c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,29 @@ +package com.loopers.domain.like; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface LikeRepository { + + // 좋아요 여부 조회 + Optional findByUserAndProduct(UserModel user, ProductModel product); + + // 사용자가 좋아요한 상품 목록 조회 + List findLikedProductsByUser(UserModel user); + + // 상품의 좋아요 수 조회 + long countByProductLiked(ProductModel product); + + // 상품의 좋아요 수 일괄 집계 + Map countByProductIdsLiked(Collection productIds); + + // 좋아요 등록 + LikeModel save(LikeModel like); + + // 좋아요 삭제 + void delete(LikeModel like); +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..e3b39b328 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,72 @@ +package com.loopers.domain.like; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +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.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + + // 좋아요 취소 또는 등록 + @Transactional + public void toggleLike(UserModel user, ProductModel product) { + var existing = likeRepository.findByUserAndProduct(user, product); + + if (existing.isPresent()) { + likeRepository.delete(existing.get()); + } else { + LikeModel newLike = new LikeModel(user, product); + likeRepository.save(newLike); + } + } + + // 좋아요 등록: 좋아요가 없으면 추가, 있으면 취소 + @Transactional + public void addLike(UserModel user, ProductModel product) { + var existing = likeRepository.findByUserAndProduct(user, product); + if (existing.isEmpty()) { + LikeModel newLike = new LikeModel(user, product); + likeRepository.save(newLike); + } + } + + // 좋아요 취소: 좋아요가 있으면 취소, 없으면 추가 + @Transactional + public void removeLike(UserModel user, ProductModel product) { + var existing = likeRepository.findByUserAndProduct(user, product); + existing.ifPresent(likeRepository::delete); + } + + // 좋아요 여부 확인 + @Transactional(readOnly = true) + public boolean isLiked(UserModel user, ProductModel product) { + return likeRepository.findByUserAndProduct(user, product).isPresent(); + } + + // 좋아요한 상품 목록 조회 + @Transactional(readOnly = true) + public List getLikedProducts(UserModel user) { + return likeRepository.findLikedProductsByUser(user); + } + + // 좋아요 수 조회 + @Transactional(readOnly = true) + public long getLikeCount(ProductModel product) { + return likeRepository.countByProductLiked(product); + } + + // 좋아요 수 일괄 집계 + @Transactional(readOnly = true) + public Map getLikeCounts(List products) { + var ids = products.stream().map(ProductModel::getId).collect(Collectors.toSet()); + return likeRepository.countByProductIdsLiked(ids); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java new file mode 100644 index 000000000..9692a57a0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -0,0 +1,63 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.common.Money; + +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Embedded; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; + +@Entity +@Table(name = "orderitems") +public class OrderItemModel extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "order_id", nullable = false) + private OrderModel order; + + @ManyToOne + @JoinColumn(name = "product_id", nullable = false) + private ProductModel product; + + @Embedded + private Quantity quantity; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "order_price")) + private Money orderPrice; + + protected OrderItemModel() { + } + + public OrderItemModel(ProductModel product, Quantity quantity, Money orderPrice) { + this.product = product; + this.quantity = quantity; + this.orderPrice = orderPrice; + } + + public OrderModel getOrder() { + return order; + } + + public ProductModel getProduct() { + return product; + } + + public Quantity getQuantity() { + return quantity; + } + + public Money getOrderPrice() { + return orderPrice; + } + + protected void setOrder(OrderModel order) { + this.order = order; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java new file mode 100644 index 000000000..b90595a2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,60 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.common.Money; + +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.OneToMany; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embedded; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; + +import java.util.List; +import java.util.ArrayList; + +@Entity +@Table(name = "orders") +public class OrderModel extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "user_id") + private UserModel user; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_price")) + private Money totalPrice; + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "order") + private List orderItems = new ArrayList<>(); + + public OrderModel() { + } + + public OrderModel(UserModel user, Money totalPrice, List orderItems) { + this.user = user; + this.totalPrice = totalPrice; + orderItems.forEach(this::addOrderItem); + } + + public UserModel getUser() { + return user; + } + + public Money getTotalPrice() { + return totalPrice; + } + + public List getOrderItems() { + return orderItems; + } + + public void addOrderItem(OrderItemModel orderItem) { + orderItems.add(orderItem); + orderItem.setOrder(this); + } +} 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..3b10eb87a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +import com.loopers.domain.user.UserModel; +import java.util.Optional; +import java.util.List; + +public interface OrderRepository { + // 주문 저장 + OrderModel save(OrderModel order); + // 주문 단건 조회 + Optional findById(Long id); + // 사용자 주문 조회 + List findByUserId(UserModel user); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..567b2bf09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,82 @@ +package com.loopers.domain.order; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.point.PointService; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +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.List; +import java.util.ArrayList; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + private final ProductService productService; + private final PointService pointService; + + @Transactional(readOnly = true) + public OrderModel getOrder(Long id) { + return orderRepository.findById(id).orElse(null); + } + + @Transactional(readOnly = true) + public List getUserOrders(UserModel user) { + return orderRepository.findByUserId(user); + } + + @Transactional + public OrderModel createOrder(UserModel user, List items) { + + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목이 비어있습니다."); + } + + List orderItems = new ArrayList<>(); + long totalPriceValue = 0; + + // 각 상품에 대해 재고 확인 및 차감, 주문 항목 생성 + for (OrderItemRequest item : items) { + if (item.quantity() == null || item.quantity() <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 1개 이상이어야 합니다."); + } + + ProductModel product = productService.getProduct(item.productId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. productId: " + item.productId()); + } + + Quantity quantity = new Quantity(item.quantity()); + + // 재고 차감 + productService.updateQuantity(item.productId(), quantity); + + // 주문 항목 가격 계산 (상품 가격 * 수량) + Money orderPrice = new Money(product.getPrice().value() * quantity.quantity()); + totalPriceValue += orderPrice.value(); + + // 주문 항목 생성 + OrderItemModel orderItem = new OrderItemModel(product, quantity, orderPrice); + orderItems.add(orderItem); + } + + Money totalPrice = new Money(totalPriceValue); + + // 포인트 차감 + pointService.use(user, totalPrice); + + // 주문 생성 및 저장 + OrderModel order = new OrderModel(user, totalPrice, orderItems); + return orderRepository.save(order); + } + + public record OrderItemRequest(Long productId, Integer quantity) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java new file mode 100644 index 000000000..27162addb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java @@ -0,0 +1,60 @@ +package com.loopers.domain.point; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Money; +import com.loopers.domain.user.UserModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Embedded; + + +@Entity +@Table(name = "point") +public class PointModel extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "user_model_id") + private UserModel user; + @Embedded + private Money point; + + public PointModel() { + } + + public PointModel(UserModel user, Money point) { + + this.user = user; + this.point = point; + } + + public UserModel getUser() { + return user; + } + + public Money getPoint() { + return point; + } + + public void charge(Money chargePoint) { + long newPointValue = this.point.value() + chargePoint.value(); + this.point = new Money(newPointValue); + } + + public void use(Money usePoint) { + if (this.point.value() < usePoint.value()) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트가 부족합니다."); + } + + if (usePoint.value() > this.point.value()) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액이 보유 포인트를 초과합니다."); + } + + long newPointValue = this.point.value() - usePoint.value(); + this.point = new Money(newPointValue); + + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java new file mode 100644 index 000000000..dd96643db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.UserModel; + +import java.util.Optional; + +public interface PointRepository { + Optional findPoint(UserModel user); + PointModel save(PointModel pointModel); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java new file mode 100644 index 000000000..ec8e4d64c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -0,0 +1,57 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.UserRepository; +import com.loopers.domain.common.Money; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.stereotype.Component; + + +import com.loopers.domain.user.UserModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +@RequiredArgsConstructor +@Component +public class PointService { + + private final PointRepository pointRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public PointModel findPoint(PointModel point) { + UserModel requestUser = point.getUser(); + var foundUser = userRepository.findById(requestUser.getId()); + if (foundUser.isEmpty()) { + return null; + } + return pointRepository.findPoint(foundUser.get()).orElse(null); + } + + @Transactional + public void charge(PointModel point) { + UserModel user = point.getUser(); + var foundUser = userRepository.findById(user.getId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "유저가 존재하지 않습니다.")); + + var existing = pointRepository.findPoint(foundUser); + if (existing.isPresent()) { + existing.get().charge(point.getPoint()); + pointRepository.save(existing.get()); + return; + } + pointRepository.save(new PointModel(foundUser, point.getPoint())); + } + + @Transactional + public void use(UserModel user, Money usePoint) { + var foundUser = userRepository.findById(user.getId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "유저가 존재하지 않습니다.")); + + var existing = pointRepository.findPoint(foundUser) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트 정보가 없습니다.")); + + existing.use(usePoint); + pointRepository.save(existing); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java new file mode 100644 index 000000000..129f4a572 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java @@ -0,0 +1,37 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public class Brand { + private String name; + + protected Brand() { + } + + public Brand(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다."); + } + this.name = name; + } + + public String name() { + return name; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Brand brand = (Brand) o; + return name != null ? name.equals(brand.name) : brand.name == null; + } + + @Override + public int hashCode() { + return name != null ? name.hashCode() : 0; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java new file mode 100644 index 000000000..d16899c0e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,75 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Embedded; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; + +@Entity +@Table(name = "product") +public class ProductModel extends BaseEntity { + + private String name; + @Embedded + @AttributeOverride(name = "name", column = @Column(name = "brand_name")) + private Brand brand; + @Embedded + private Money price; + @Embedded + private Quantity quantity; + private Long likeCount; + + public ProductModel() { + } + + public ProductModel(String name, Brand brand, Money price, Quantity quantity) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다."); + } + this.name = name; + this.brand = brand; + this.price = price; + this.quantity = quantity; + this.likeCount = 0L; + } + + public String getName() { + return name; + } + + public Brand getBrand() { + return brand; + } + + public Money getPrice() { + return price; + } + + public Quantity getQuantity() { + return quantity; + } + + public Long getLikeCount() { + return likeCount; + } + + public void setLikeCount(Long likeCount) { + this.likeCount = likeCount; + } + + public void decreaseQuantity(Quantity quantityToDecrease) { + if (this.quantity.quantity() < quantityToDecrease.quantity()) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + + this.quantity = new Quantity(this.quantity.quantity() - quantityToDecrease.quantity()); + + } + +} 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..4ec09074b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,24 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductRepository { + // 상품 목록 조회(다건) + Page findAll(Pageable pageable); + + // 브랜드로 상품 목록 조회(다건) + Page findByBrandName(String brandName, Pageable pageable); + + // 상품 상세 조회(단건) + Optional findById(Long id); + + // 상품 ID 목록으로 조회 + List findAllById(Set ids); + +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..53e4dd15e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,91 @@ +package com.loopers.domain.product; + +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.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PageImpl; + +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.common.Quantity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + + private final LikeRepository likeRepository; + + @Transactional(readOnly = true) + public Page getProducts(Pageable pageable, String sort, String brandName) { + Page productPage; + if (brandName != null && !brandName.isBlank()) { + productPage = productRepository.findByBrandName(brandName, pageable); + } else { + productPage = productRepository.findAll(pageable); + } + + List products = productPage.getContent(); + Map likeCounts = likeRepository + .countByProductIdsLiked(products.stream().map(ProductModel::getId).collect(Collectors.toSet())); + products.forEach(product -> product.setLikeCount(likeCounts.getOrDefault(product.getId(), 0L))); + + // likes_desc 정렬은 메모리에서 처리 + if ("likes_desc".equals(sort)) { + products.sort((a, b) -> Long.compare( + b.getLikeCount() != null ? b.getLikeCount() : 0L, + a.getLikeCount() != null ? a.getLikeCount() : 0L)); + + // 정렬된 리스트로 새로운 Page 객체 생성하여 반환 + return new PageImpl<>(products, pageable, productPage.getTotalElements()); + } + + return productPage; + } + + + @Transactional(readOnly = true) + public ProductModel getProduct(Long id) { + ProductModel product = productRepository.findById(id).orElse(null); + if (product != null) { + product.setLikeCount(likeRepository.countByProductLiked(product)); + } + return product; + } + + @Transactional(readOnly = true) + public Optional getQuantity(Long id) { + ProductModel product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다.")); + return Optional.of(product.getQuantity()); + } + + @Transactional + public void updateQuantity(Long id, Quantity quantityToDecrease) { + ProductModel product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다.")); + + product.decreaseQuantity(quantityToDecrease); + } + + @Transactional(readOnly = true) + public List getProductsByIds(List productIds) { + Set ids = productIds.stream().collect(Collectors.toSet()); + List products = productRepository.findAllById(ids); + Map likeCounts = likeRepository + .countByProductIdsLiked(products.stream().map(ProductModel::getId).collect(Collectors.toSet())); + products.forEach(product -> product.setLikeCount(likeCounts.getOrDefault(product.getId(), 0L))); + return products; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java new file mode 100644 index 000000000..97eab71e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java @@ -0,0 +1,41 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public class BirthDate { + private String birthDate; + + protected BirthDate() { + } + + public BirthDate(String birthDate) { + //생년월일 validation check + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + if (!birthDate.matches("^\\d{4}-\\d{2}-\\d{2}$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일이 `yyyy-MM-dd` 형식에 맞아야 합니다."); + } + this.birthDate = birthDate; + } + + public String birthDate() { + return birthDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BirthDate birthDate1 = (BirthDate) o; + return birthDate != null ? birthDate.equals(birthDate1.birthDate) : birthDate1.birthDate == null; + } + + @Override + public int hashCode() { + return birthDate != null ? birthDate.hashCode() : 0; + } +} 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..9d7829096 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java @@ -0,0 +1,44 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public class Email { + private String email; + + protected Email() { + } + + public Email(String email) { + // 이메일 validation check + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); + } + + if (!email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63}$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일이 `xx@yy.zz` 형식에 맞아야 합니다."); + } + this.email = email; + } + + public String email() { + return email; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + Email email1 = (Email) o; + return email != null ? email.equals(email1.email) : email1.email == null; + } + + @Override + public int hashCode() { + return email != null ? email.hashCode() : 0; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java new file mode 100644 index 000000000..0767c451a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java @@ -0,0 +1,38 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public class Gender { + private String gender; + + protected Gender() { + } + + public Gender(String gender) { + //성별 체크 + if (gender == null || gender.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "성별은 비어있을 수 없습니다."); + } + this.gender = gender; + } + + public String gender() { + return gender; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Gender gender1 = (Gender) o; + return gender != null ? gender.equals(gender1.gender) : gender1.gender == null; + } + + @Override + public int hashCode() { + return gender != null ? gender.hashCode() : 0; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java new file mode 100644 index 000000000..db192f67b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.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.Embeddable; + +@Embeddable +public class UserId { + private String userId; + + protected UserId() { + } + + public UserId(String userId) { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "UserId는 비어있을 수 없습니다."); + } + if (!userId.matches("^[a-zA-Z0-9_-]{1,10}$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "ID 가 `영문 및 숫자 10자 이내` 형식에 맞아야 합니다."); + } + this.userId = userId; + } + + public String userId() { + return userId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserId userId1 = (UserId) o; + return userId != null ? userId.equals(userId1.userId) : userId1.userId == null; + } + + @Override + public int hashCode() { + return userId != null ? userId.hashCode() : 0; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java new file mode 100644 index 000000000..b50e14e37 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -0,0 +1,49 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Embedded; + +@Entity +@Table(name = "user") +public class UserModel extends BaseEntity { + + @Embedded + private UserId userId; + + @Embedded + private Email email; + + @Embedded + private Gender gender; + + @Embedded + private BirthDate birthDate; + + protected UserModel() { + } + + public UserModel(UserId userId, Email email, Gender gender, BirthDate birthDate) { + this.userId = userId; + this.email = email; + this.gender = gender; + this.birthDate = birthDate; + } + + public UserId getUserId() { + return this.userId; + } + + public Email getEmail() { + return this.email; + } + + public Gender getGender() { + return this.gender; + } + + public BirthDate getBirthDate() { + return this.birthDate; + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..a6caeebe5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + Optional find(UserId userId); + + Optional findById(Long id); + UserModel save(UserModel userModel); +} \ No newline at end of file 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/UserService.java new file mode 100644 index 000000000..af6ee8d1c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,31 @@ +package com.loopers.domain.user; + +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.Optional; + +@RequiredArgsConstructor +@Component +public class UserService { + + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public UserModel getUser(UserId userId) { + return userRepository.find(userId).orElse(null); + } + + @Transactional + public UserModel signUp(UserModel userModel) { + Optional user = userRepository.find(userModel.getUserId()); + + if (user.isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "[userId = " + userModel.getUserId().userId() + "] 아이디가 중복되었습니다."); + } + return userRepository.save(userModel); + } +} \ No newline at end of file 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..d8120f35d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserAndProduct(UserModel user, ProductModel product); + + List findByUser(UserModel user); + + long countByProduct(ProductModel product); + + @Query("SELECT l.product.id as productId, COUNT(l) as likeCount " + + "FROM LikeModel l " + + "WHERE l.product.id IN :productIds " + + "GROUP BY l.product.id") + Map countByProductIds(@Param("productIds") Set productIds); +} \ No newline at end of file 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..a718d1cb1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,59 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + // 좋아요 여부 조회 + @Override + public Optional findByUserAndProduct(UserModel user, ProductModel product) { + return likeJpaRepository.findByUserAndProduct(user, product); + } + + // 사용자가 좋아요한 상품 목록 조회 + @Override + public List findLikedProductsByUser(UserModel user) { + return likeJpaRepository.findByUser(user).stream() + .map(LikeModel::getProduct) + .collect(Collectors.toList()); + } + + // 상품의 좋아요 수 조회 + @Override + public long countByProductLiked(ProductModel product) { + return likeJpaRepository.countByProduct(product); + } + + // 상품의 좋아요 수 일괄 집계 + @Override + public Map countByProductIdsLiked(Collection productIds) { + return likeJpaRepository.countByProductIds(productIds.stream().collect(Collectors.toSet())); + } + + // 좋아요 등록 + @Override + public LikeModel save(LikeModel like) { + return likeJpaRepository.save(like); + } + + // 좋아요 삭제 + @Override + public void delete(LikeModel like) { + likeJpaRepository.delete(like); + } +} \ No newline at end of file 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..3de048b1c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderJpaRepository extends JpaRepository { + + List findByUserId(Long id); +} \ No newline at end of file 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..e63cbe72c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.user.UserModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public OrderModel save(OrderModel order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public List findByUserId(UserModel user) { + return orderJpaRepository.findByUserId(user.getId()); + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java new file mode 100644 index 000000000..e4ac7e4ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.point; + +import com.loopers.domain.point.PointModel; +import com.loopers.domain.user.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PointJpaRepository extends JpaRepository { + Optional findByUser(UserModel user); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java new file mode 100644 index 000000000..4b81b3b0b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.point; + +import com.loopers.domain.point.PointModel; +import com.loopers.domain.point.PointRepository; +import com.loopers.domain.user.UserModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class PointRepositoryImpl implements PointRepository { + private final PointJpaRepository pointJpaRepository; + + @Override + public Optional findPoint(UserModel user) { + return pointJpaRepository.findByUser(user); + } + + @Override + public PointModel save(PointModel pointModel) { + return pointJpaRepository.save(pointModel); + } +} 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..8a74951e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,15 @@ +// ProductJpaRepository.java (수정) +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +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; + +public interface ProductJpaRepository extends JpaRepository { + + @Query("SELECT p FROM ProductModel p WHERE p.brand.name = :brandName ORDER BY p.likeCount DESC") + Page findByBrandName(@Param("brandName") String brandName, Pageable pageable); +} \ No newline at end of file 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..9d0c5a366 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAll(pageable); + } + + @Override + public Page findByBrandName(String brandName, Pageable pageable) { + return productJpaRepository.findByBrandName(brandName, pageable); + } + + @Override + public List findAllById(Set ids) { + return productJpaRepository.findAllById(ids); + } + +} 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 new file mode 100644 index 000000000..2e06ebfd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserId; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByUserId(UserId userId); +} \ No newline at end of file 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 new file mode 100644 index 000000000..d6dfe16cc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; +import com.loopers.domain.user.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + private final UserJpaRepository userJpaRepository; + + @Override + public Optional find(UserId userId) { + return userJpaRepository.findByUserId(userId); + } + + @Override + public Optional findById(Long id) { + return userJpaRepository.findById(id); + } + + @Override + public UserModel save(UserModel userModel) { + return userJpaRepository.save(userModel); + } +} \ No newline at end of file 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 20b2809c8..28b7bb38d 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 @@ -8,6 +8,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +49,21 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String headerName = e.getHeaderName(); + String message = String.format("필수 요청 헤더 '%s'가 누락되었습니다.", headerName); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + String errorMessage = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + return failureResponse(ErrorType.BAD_REQUEST, errorMessage); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; 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..7142ac9be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.user.UserId; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Like V1 API", description = "Loopers 좋아요 API 입니다.") +public interface LikeV1ApiSpec { + + @Operation( + summary = "상품 좋아요 등록", + description = "상품에 좋아요를 등록합니다. 멱등하게 동작하여 이미 좋아요가 있으면 아무것도 하지 않습니다." + ) + ApiResponse addLike( + @Parameter(name = "X-USER-ID", description = "좋아요를 등록할 유저의 ID", required = true) + UserId userId, + @Parameter(name = "productId", description = "좋아요를 등록할 상품의 ID", required = true) + Long productId + ); + + @Operation( + summary = "상품 좋아요 취소", + description = "상품의 좋아요를 취소합니다. 멱등하게 동작하여 이미 좋아요가 없으면 아무것도 하지 않습니다." + ) + ApiResponse removeLike( + @Parameter(name = "X-USER-ID", description = "좋아요를 취소할 유저의 ID", required = true) + UserId userId, + @Parameter(name = "productId", description = "좋아요를 취소할 상품의 ID", required = true) + Long productId + ); + + @Operation( + summary = "내가 좋아요 한 상품 목록 조회", + description = "유저가 좋아요한 상품 목록을 조회합니다." + ) + ApiResponse getLikedProducts( + @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true) + UserId userId + ); +} + 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..4f4bdd7b5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.user.UserId; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/like") +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeFacade likeFacade; + + @PostMapping("/products/{productId}") + @Override + public ApiResponse addLike( + @RequestHeader(value = "X-USER-ID") UserId userId, + @PathVariable("productId") Long productId + ) { + likeFacade.addLike(userId, productId); + return ApiResponse.success(null); + } + + @DeleteMapping("/products/{productId}") + @Override + public ApiResponse removeLike( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId, + @PathVariable("productId") Long productId + ) { + likeFacade.removeLike(userId, productId); + return ApiResponse.success(null); + } + + @GetMapping("/products") + @Override + public ApiResponse getLikedProducts( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId + ) { + List products = likeFacade.getLikedProducts(userId); + LikeV1Dto.LikedProductsResponse response = LikeV1Dto.LikedProductsResponse.from(products); + return ApiResponse.success(response); + } +} + 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..ea9c7b05f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.product.ProductModel; +import java.util.List; +import java.util.stream.Collectors; + +public class LikeV1Dto { + public record LikeResponse(boolean liked) { + public static LikeResponse from(boolean liked) { + return new LikeResponse(liked); + } + } + + public record LikedProductsResponse(List products) { + public static LikedProductsResponse from(List products) { + List productInfos = products.stream() + .map(product -> new ProductInfo( + product.getId(), + product.getName(), + product.getBrand(), + product.getPrice(), + product.getLikeCount() + )) + .collect(Collectors.toList()); + return new LikedProductsResponse(productInfos); + } + } + + public record LikeCountResponse(Long count) { + public static LikeCountResponse from(long count) { + return new LikeCountResponse(count); + } + } + + public record ToggleLikeRequest(Long productId) {} +} + 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..3011b1d3b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.user.UserId; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Order V1 API", description = "Loopers 주문 API 입니다.") +public interface OrderV1ApiSpec { + + @Operation( + summary = "주문 생성", + description = "여러 상품을 주문하고 결제합니다. 재고 차감 및 포인트 차감이 자동으로 처리됩니다." + ) + ApiResponse createOrder( + @Parameter(name = "X-USER-ID", description = "주문할 유저의 ID", required = true) + UserId userId, + @Schema(name = "주문 요청", description = "주문할 상품 목록") + OrderV1Dto.CreateOrderRequest request + ); + + @Operation( + summary = "주문 조회", + description = "주문 ID로 주문을 조회합니다." + ) + ApiResponse getOrder( + @Parameter(name = "id", description = "조회할 주문의 ID", required = true) + Long id + ); + + @Operation( + summary = "유저 주문 목록 조회", + description = "유저의 주문 목록을 조회합니다." + ) + ApiResponse getUserOrders( + @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true) + UserId userId + ); +} + 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..f72bd7887 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,65 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.user.UserId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createOrder( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId, + @Valid @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + if (request.items() == null || request.items().isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 비어 있을 수 없습니다."); + } + List items = request.items().stream() + .map(item -> new OrderService.OrderItemRequest(item.productId(), item.quantity())) + .collect(Collectors.toList()); + + OrderInfo info = orderFacade.createOrder(userId, items); + OrderV1Dto.OrderResponse response = OrderV1Dto.OrderResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/{id}") + @Override + public ApiResponse getOrder( + @PathVariable("id") Long id + ) { + OrderInfo info = orderFacade.getOrder(id); + OrderV1Dto.OrderResponse response = OrderV1Dto.OrderResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping + @Override + public ApiResponse getUserOrders( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId + ) { + List orders = orderFacade.getUserOrders(userId); + OrderV1Dto.OrdersResponse response = OrderV1Dto.OrdersResponse.from(orders); + return ApiResponse.success(response); + } +} + 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..efed3f2b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.common.Money; +import com.loopers.application.order.OrderItemInfo; +import java.util.List; + +public class OrderV1Dto { + public record OrderItemResponse(Long productId, Integer quantity, Money price) { + public static OrderItemResponse from(OrderItemInfo item) { + return new OrderItemResponse( + item.productId(), + item.quantity(), + item.orderPrice() + ); + } + } + + public record OrderResponse(Long id, String userId, Money totalPrice, List orderItems) { + public static OrderResponse from(OrderInfo info) { + List items = info.orderItems().stream() + .map(item -> OrderItemResponse.from(item)) + .toList(); + return new OrderResponse( + info.id(), + info.userId().toString(), + info.totalPrice(), + items + ); + } + } + + public record OrdersResponse(List orders) { + public static OrdersResponse from(List orders) { + List orderResponses = orders.stream() + .map(info -> OrderResponse.from(info)) + .toList(); + return new OrdersResponse(orderResponses); + } + } + + public record CreateOrderRequest(List items) { + public record OrderItemRequest(Long productId, Integer quantity) {} + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java new file mode 100644 index 000000000..f61b108f9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.domain.user.UserId; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Point V1 API", description = "Loopers 포인트 API 입니다.") +public interface PointV1ApiSpec { + + @Operation( + summary = "포인트 조회", + description = "헤더의 유저 ID로 포인트를 조회합니다." + ) + ApiResponse getPoint( + @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true) + UserId userId + ); + + @Operation( + summary = "포인트 충전", + description = "헤더의 유저 ID로 포인트를 충전합니다." + ) + ApiResponse chargePoint( + @Parameter(name = "X-USER-ID", description = "충전할 유저의 ID", required = true) + UserId userId, + @Schema(name = "포인트 충전 요청", description = "충전할 포인트 정보") + PointV1Dto.ChargeRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java new file mode 100644 index 000000000..ffa4bc458 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.application.point.PointFacade; +import com.loopers.application.point.PointInfo; +import com.loopers.domain.common.Money; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import jakarta.validation.constraints.NotBlank; +import com.loopers.domain.user.UserId; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/points") +public class PointV1Controller implements PointV1ApiSpec { + + private final PointFacade pointFacade; + + @GetMapping + @Override + public ApiResponse getPoint( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId + ) { + PointInfo info = pointFacade.getPoint(userId); + PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(info); + return ApiResponse.success(response); + } + + @PostMapping("/charge") + @Override + public ApiResponse chargePoint( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId, + @Valid @RequestBody PointV1Dto.ChargeRequest request + ) { + PointInfo info = pointFacade.chargePoint(userId, request.point()); + PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(info); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java new file mode 100644 index 000000000..81a805d96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.application.point.PointInfo; +import com.loopers.domain.common.Money; + +public class PointV1Dto { + public record PointResponse(String userId, Money point) { + public static PointResponse from(PointInfo info) { + return new PointResponse( + info.user().getUserId().userId(), + info.point() + ); + } + } + + public record ChargeRequest( + Money point + ) {} +} 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..803e9f0ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,43 @@ +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.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Product V1 API", description = "Loopers 상품 API 입니다.") +public interface ProductV1ApiSpec { + + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 페이징하여 조회합니다. page, size, sort, brandId 파라미터를 사용할 수 있습니다. sort 옵션: latest(최신순), price_asc(가격 오름차순), likes_desc(좋아요 수 내림차순)" + ) + ApiResponse getProducts( + @Parameter(name = "sort", description = "정렬 옵션 (latest, price_asc, likes_desc)", required = false) + String sort, + @Parameter(name = "brandId", description = "브랜드 이름으로 필터링 (brandId는 브랜드 이름을 의미)", required = false) + String brandId, + @Parameter(name = "pageable", description = "페이징 정보 (page: 페이지 번호, size: 페이지 크기)", required = false) + Pageable pageable + ); + + @Operation( + summary = "상품 상세 조회", + description = "상품 ID로 상품 상세 정보를 조회합니다." + ) + ApiResponse getProduct( + @Parameter(name = "id", description = "조회할 상품의 ID", required = true) + Long id + ); + + @Operation( + summary = "상품 재고 조회", + description = "상품 ID로 상품 재고를 조회합니다." + ) + ApiResponse getQuantity( + @Parameter(name = "id", description = "조회할 상품의 ID", required = true) + Long id + ); +} + 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..db8f9ebc0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,84 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.common.Quantity; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse getProducts( + @RequestParam(value = "sort", required = false, defaultValue = "latest") String sort, + @RequestParam(value = "brandId", required = false) String brandId, + @PageableDefault(size = 20) Pageable pageable + ) { + Pageable sortedPageable = convertSortToPageable(sort, pageable); + Page productPage = productFacade.getProducts(sortedPageable, sort, brandId); + ProductV1Dto.ProductsResponse response = ProductV1Dto.ProductsResponse.from(productPage); + return ApiResponse.success(response); + } + + private Pageable convertSortToPageable(String sort, Pageable pageable) { + Sort.Direction direction; + String property; + + switch (sort) { + case "latest": + property = "id"; + direction = Sort.Direction.DESC; + break; + case "price_asc": + property = "price"; + direction = Sort.Direction.ASC; + break; + case "likes_desc": + // likes_desc는 메모리에서 정렬하므로 여기서는 기본 정렬 사용 + property = "id"; + direction = Sort.Direction.DESC; + break; + default: + property = "id"; + direction = Sort.Direction.DESC; + } + + return org.springframework.data.domain.PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + Sort.by(direction, property) + ); + } + + @GetMapping("/{id}") + @Override + public ApiResponse getProduct( + @PathVariable("id") Long id + ) { + ProductInfo info = productFacade.getProduct(id); + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/{id}/quantity") + @Override + public ApiResponse getQuantity( + @PathVariable("id") Long id + ) { + Quantity quantity = productFacade.getQuantity(id); + ProductV1Dto.QuantityResponse response = ProductV1Dto.QuantityResponse.from(quantity); + return ApiResponse.success(response); + } +} + 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..52accf147 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.common.Quantity; +import org.springframework.data.domain.Page; + +public class ProductV1Dto { + public record ProductResponse(Long id, String name, String brand, long price, Long likeCount) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.name(), + info.brand().name(), + info.price().value(), + info.likeCount() + ); + } + } + + public record PageInfo(int page, int size, long totalElements, int totalPages, boolean hasNext, boolean hasPrevious) { + public static PageInfo from(Page page) { + return new PageInfo( + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.hasNext(), + page.hasPrevious() + ); + } + } + + public record ProductsResponse(java.util.List products, PageInfo pageInfo) { + public static ProductsResponse from(Page productPage) { + java.util.List productResponses = productPage.getContent().stream() + .map(ProductResponse::from) + .toList(); + return new ProductsResponse(productResponses, PageInfo.from(productPage)); + } + } + + public record QuantityResponse(Integer quantity) { + public static QuantityResponse from(Quantity quantity) { + return new QuantityResponse(quantity.quantity()); + } + } +} + 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 new file mode 100644 index 000000000..f2beac356 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.user.UserId; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User V1 API", description = "Loopers 유저 API 입니다.") +public interface UserV1ApiSpec { + + @Operation( + summary = "유저 회원가입", + description = "새로운 유저를 회원가입합니다." + ) + ApiResponse signup( + @Schema(name = "회원가입 요청", description = "회원가입할 유저의 정보") + UserV1Dto.SignupRequest request + ); + + @Operation( + summary = "내 정보 조회", + description = "X-USER-ID 헤더를 통해 현재 유저의 정보를 조회합니다." + ) + ApiResponse getMe( + @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true) + UserId userId + ); + + @Operation( + summary = "유저 조회", + description = "ID로 유저를 조회합니다." + ) + ApiResponse getUser( + @Schema(name = "유저 ID", description = "조회할 유저의 ID") + UserId userId + ); +} 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 new file mode 100644 index 000000000..bd1c4abe6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.UserId; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signup( + @Valid @RequestBody UserV1Dto.SignupRequest request + ) { + UserInfo info = userFacade.signup(request.userId(), request.email(), request.gender(), request.birthDate()); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/me") + @Override + public ApiResponse getMe( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId + ) { + UserInfo info = userFacade.getUser(userId); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/{userId}") + @Override + public ApiResponse getUser( + @PathVariable(value = "userId") UserId userId + ) { + UserInfo info = userFacade.getUser(userId); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); + return ApiResponse.success(response); + } +} 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 new file mode 100644 index 000000000..1ddf041e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; +import jakarta.validation.constraints.NotBlank; + +public class UserV1Dto { + public record UserResponse(String userId, String email, String birthDate) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.userId(), + info.email(), + info.birthDate() + ); + } + } + + public record SignupRequest( + @NotBlank(message = "userId는 필수입니다.") + String userId, + @NotBlank(message = "email은 필수입니다.") + String email, + @NotBlank(message = "gender는 필수입니다.") + String gender, + @NotBlank(message = "birthDate는 필수입니다.") + String birthDate + ) {} +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java new file mode 100644 index 000000000..1cc2ef6ba --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -0,0 +1,40 @@ +package com.loopers.domain.like; + +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 com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; + +class LikeModelTest { + @DisplayName("좋아요 행위 ") + @Nested + class Create { + @DisplayName("좋아요 등록이 정상 처리된다") + @Test + void createsLikeModel_whenLikeIsCreated() { + // arrange + UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + ProductModel product = new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)); + + // act + LikeModel likeModel = new LikeModel(user, product); + + // assert + assertAll( + () -> assertThat(likeModel.getUser()).isEqualTo(user), + () -> assertThat(likeModel.getProduct()).isEqualTo(product) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java new file mode 100644 index 000000000..a4fe27545 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -0,0 +1,179 @@ +package com.loopers.domain.like; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.BirthDate; +import com.loopers.infrastructure.like.LikeJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +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; + +@SpringBootTest +class LikeServiceIntegrationTest { + @Autowired + private LikeService likeService; + + @Autowired + private LikeJpaRepository likeJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("좋아요 등록/취소") + @Nested + class LikeManagement { + + @DisplayName("좋아요 등록이 정상 처리된다") + @Test + void createsLike_whenAddLikeIsCalled() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + + // act + likeService.addLike(user, product); + + // assert + boolean isLiked = likeService.isLiked(user, product); + assertThat(isLiked).isTrue(); + } + + @DisplayName("좋아요 취소가 정상 처리된다") + @Test + void removesLike_whenRemoveLikeIsCalled() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + likeService.addLike(user, product); + + // act + likeService.removeLike(user, product); + + // assert + boolean isLiked = likeService.isLiked(user, product); + assertThat(isLiked).isFalse(); + } + + @DisplayName("좋아요 등록: 이미 좋아요가 있으면 취소한다") + @Test + void cancelsLike_whenAddLikeIsCalledOnLikedProduct() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + likeService.addLike(user, product); // 첫 번째 호출: 좋아요 추가 + assertThat(likeService.isLiked(user, product)).isTrue(); + + // act + likeService.addLike(user, product); // 두 번째 호출: 좋아요 취소 + + // assert + assertThat(likeService.isLiked(user, product)).isFalse(); + } + + @DisplayName("좋아요 취소: 이미 좋아요가 없으면 추가한다") + @Test + void addsLike_whenRemoveLikeIsCalledOnUnlikedProduct() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + // 처음부터 좋아요 없음 + assertThat(likeService.isLiked(user, product)).isFalse(); + + // act + likeService.removeLike(user, product); // 좋아요 추가 + + // assert + assertThat(likeService.isLiked(user, product)).isTrue(); + } + + @DisplayName("좋아요 토글이 정상 동작한다") + @Test + void togglesLike_whenToggleLikeIsCalled() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + + // act & assert - 첫 번째 호출: 좋아요 등록 + likeService.toggleLike(user, product); + assertThat(likeService.isLiked(user, product)).isTrue(); + + // act & assert - 두 번째 호출: 좋아요 취소 + likeService.toggleLike(user, product); + assertThat(likeService.isLiked(user, product)).isFalse(); + } + } + + @DisplayName("좋아요 수 조회") + @Nested + class LikeCount { + + @DisplayName("상품의 좋아요 수를 정확히 조회한다") + @Test + void returnsCorrectLikeCount_whenMultipleUsersLikeProduct() { + // arrange + UserModel user1 = userJpaRepository.save( + new UserModel(new UserId("user1"), new Email("user1@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + UserModel user2 = userJpaRepository.save( + new UserModel(new UserId("user2"), new Email("user2@user.com"), new Gender("female"), new BirthDate("2000-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + likeService.addLike(user1, product); + likeService.addLike(user2, product); + + // act + long likeCount = likeService.getLikeCount(product); + + // assert + assertThat(likeCount).isEqualTo(2L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java new file mode 100644 index 000000000..6e352674c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -0,0 +1,55 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.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; +import static org.junit.jupiter.api.Assertions.assertAll; + +class OrderItemModelTest { + @DisplayName("주문 항목 모델 생성") + @Nested + class Create { + + @DisplayName("주문 항목이 정상적으로 생성된다") + @Test + void createsOrderItem_whenValidParameters() { + // arrange + ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + Quantity quantity = new Quantity(3); + Money orderPrice = new Money(30000); + + // act + OrderItemModel orderItem = new OrderItemModel(product, quantity, orderPrice); + + // assert + assertAll( + () -> assertThat(orderItem).isNotNull(), + () -> assertThat(orderItem.getProduct()).isEqualTo(product), + () -> assertThat(orderItem.getQuantity()).isEqualTo(quantity), + () -> assertThat(orderItem.getOrderPrice()).isEqualTo(orderPrice) + ); + } + + @DisplayName("주문 항목의 가격이 상품 가격과 수량의 곱과 일치한다") + @Test + void createsOrderItem_withCorrectPriceCalculation() { + // arrange + ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + Quantity quantity = new Quantity(2); + Money expectedOrderPrice = new Money(20000); // 10000 * 2 + + // act + OrderItemModel orderItem = new OrderItemModel(product, quantity, expectedOrderPrice); + + // assert + assertThat(orderItem.getOrderPrice().value()).isEqualTo(expectedOrderPrice.value()); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java new file mode 100644 index 000000000..64b5b5bf7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -0,0 +1,128 @@ +package com.loopers.domain.order; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class OrderModelTest { + @DisplayName("주문 모델 생성") + @Nested + class Create { + + @DisplayName("주문이 정상적으로 생성된다") + @Test + void createsOrder_whenValidParameters() { + // arrange + UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + Money totalPrice = new Money(30000); + ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + OrderItemModel orderItem = new OrderItemModel(product, new Quantity(3), new Money(30000)); + List orderItems = List.of(orderItem); + + // act + OrderModel order = new OrderModel(user, totalPrice, orderItems); + + // assert + assertAll( + () -> assertThat(order).isNotNull(), + () -> assertThat(order.getUser()).isEqualTo(user), + () -> assertThat(order.getTotalPrice()).isEqualTo(totalPrice), + () -> assertThat(order.getOrderItems()).hasSize(1), + () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem) + ); + } + + @DisplayName("여러 주문 항목이 포함된 주문이 정상적으로 생성된다") + @Test + void createsOrder_whenMultipleOrderItems() { + // arrange + UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + Money totalPrice = new Money(50000); + ProductModel product1 = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + ProductModel product2 = new ProductModel("product2", new Brand("Samsung"), new Money(20000), new Quantity(5)); + OrderItemModel orderItem1 = new OrderItemModel(product1, new Quantity(2), new Money(20000)); + OrderItemModel orderItem2 = new OrderItemModel(product2, new Quantity(1), new Money(20000)); + List orderItems = List.of(orderItem1, orderItem2); + + // act + OrderModel order = new OrderModel(user, totalPrice, orderItems); + + // assert + assertAll( + () -> assertThat(order).isNotNull(), + () -> assertThat(order.getUser()).isEqualTo(user), + () -> assertThat(order.getTotalPrice()).isEqualTo(totalPrice), + () -> assertThat(order.getOrderItems()).hasSize(2), + () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem1), + () -> assertThat(order.getOrderItems().get(1)).isEqualTo(orderItem2) + ); + } + } + + @DisplayName("주문 항목 추가") + @Nested + class AddOrderItem { + + @DisplayName("주문 항목이 정상적으로 추가된다") + @Test + void addsOrderItem_whenValidOrderItem() { + // arrange + UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + Money totalPrice = new Money(10000); + OrderModel order = new OrderModel(user, totalPrice, new java.util.ArrayList<>()); + ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + OrderItemModel orderItem = new OrderItemModel(product, new Quantity(1), new Money(10000)); + + // act + order.addOrderItem(orderItem); + + // assert + assertAll( + () -> assertThat(order.getOrderItems()).hasSize(1), + () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem), + () -> assertThat(orderItem.getOrder()).isEqualTo(order) + ); + } + + @DisplayName("여러 주문 항목이 순차적으로 추가된다") + @Test + void addsMultipleOrderItems_whenCalledMultipleTimes() { + // arrange + UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + Money totalPrice = new Money(30000); + OrderModel order = new OrderModel(user, totalPrice, new java.util.ArrayList<>()); + ProductModel product1 = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + ProductModel product2 = new ProductModel("product2", new Brand("Samsung"), new Money(20000), new Quantity(5)); + OrderItemModel orderItem1 = new OrderItemModel(product1, new Quantity(1), new Money(10000)); + OrderItemModel orderItem2 = new OrderItemModel(product2, new Quantity(1), new Money(20000)); + + // act + order.addOrderItem(orderItem1); + order.addOrderItem(orderItem2); + + // assert + assertAll( + () -> assertThat(order.getOrderItems()).hasSize(2), + () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem1), + () -> assertThat(order.getOrderItems().get(1)).isEqualTo(orderItem2), + () -> assertThat(orderItem1.getOrder()).isEqualTo(order), + () -> assertThat(orderItem2.getOrder()).isEqualTo(order) + ); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java new file mode 100644 index 000000000..b18554270 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -0,0 +1,236 @@ +package com.loopers.domain.order; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.point.PointModel; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.infrastructure.point.PointJpaRepository; +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.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 OrderServiceIntegrationTest { + @Autowired + private OrderService orderService; + + @Autowired + private OrderJpaRepository orderJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private PointJpaRepository pointJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + + @DisplayName("정상 주문이 성공적으로 생성된다") + @Test + void createsOrder_whenValidOrderRequest() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + pointJpaRepository.save( + new PointModel(user, new Money(50000)) + ); + ProductModel product1 = productJpaRepository.save( + new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)) + ); + ProductModel product2 = productJpaRepository.save( + new ProductModel("product2", new Brand("Samsung"), new Money(20000), new Quantity(5)) + ); + + List items = List.of( + new OrderService.OrderItemRequest(product1.getId(), 2), + new OrderService.OrderItemRequest(product2.getId(), 1) + ); + + // act + OrderModel order = orderService.createOrder(user, items); + + // assert + assertAll( + () -> assertThat(order).isNotNull(), + () -> assertThat(order.getUser()).isEqualTo(user), + () -> assertThat(order.getTotalPrice().value()).isEqualTo(40000), // 10000*2 + 20000*1 + () -> assertThat(order.getOrderItems()).hasSize(2), + () -> assertThat(order.getOrderItems().get(0).getProduct().getId()).isEqualTo(product1.getId()), + () -> assertThat(order.getOrderItems().get(0).getQuantity().quantity()).isEqualTo(2), + () -> assertThat(order.getOrderItems().get(1).getProduct().getId()).isEqualTo(product2.getId()), + () -> assertThat(order.getOrderItems().get(1).getQuantity().quantity()).isEqualTo(1) + ); + } + + @DisplayName("재고 부족 시 주문 생성이 실패한다") + @Test + void throwsException_whenInsufficientStock() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + pointJpaRepository.save( + new PointModel(user, new Money(50000)) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(5)) + ); + + List items = List.of( + new OrderService.OrderItemRequest(product.getId(), 10) // 재고보다 많은 수량 + ); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderService.createOrder(user, items); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).contains("재고가 부족합니다"); + } + + @DisplayName("포인트 부족 시 주문 생성이 실패한다") + @Test + void throwsException_whenInsufficientPoints() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + pointJpaRepository.save( + new PointModel(user, new Money(10000)) // 부족한 포인트 + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product1", new Brand("Apple"), new Money(20000), new Quantity(10)) + ); + + List items = List.of( + new OrderService.OrderItemRequest(product.getId(), 1) // 20000원 필요 + ); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderService.createOrder(user, items); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).contains("포인트가 부족합니다"); + } + + @DisplayName("존재하지 않는 상품으로 주문 시 실패한다") + @Test + void throwsException_whenProductNotFound() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + pointJpaRepository.save( + new PointModel(user, new Money(50000)) + ); + + List items = List.of( + new OrderService.OrderItemRequest(999L, 1) // 존재하지 않는 상품 ID + ); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderService.createOrder(user, items); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(exception.getMessage()).contains("상품을 찾을 수 없습니다"); + } + + @DisplayName("주문 생성 시 재고가 정확히 차감된다") + @Test + void decreasesStock_whenOrderIsCreated() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + pointJpaRepository.save( + new PointModel(user, new Money(50000)) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)) + ); + int initialQuantity = product.getQuantity().quantity(); + + List items = List.of( + new OrderService.OrderItemRequest(product.getId(), 3) + ); + + // act + orderService.createOrder(user, items); + + // assert + ProductModel updatedProduct = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(updatedProduct.getQuantity().quantity()).isEqualTo(initialQuantity - 3); + } + + @DisplayName("주문 생성 시 포인트가 정확히 차감된다") + @Test + void decreasesPoints_whenOrderIsCreated() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + PointModel point = pointJpaRepository.save( + new PointModel(user, new Money(50000)) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)) + ); + long initialPoints = point.getPoint().value(); + + List items = List.of( + new OrderService.OrderItemRequest(product.getId(), 2) + ); + + // act + orderService.createOrder(user, items); + + // assert + PointModel updatedPoint = pointJpaRepository.findByUser(user).orElseThrow(); + assertThat(updatedPoint.getPoint().value()).isEqualTo(initialPoints - 20000); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java new file mode 100644 index 000000000..f84cfac9e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java @@ -0,0 +1,42 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.Email; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.Gender; +import com.loopers.domain.common.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; +import java.time.LocalDate; + +class PointModelTest { + @DisplayName("포인트 ") + @Nested + class Create { + + @DisplayName("0 이하의 정수로 포인트를 충전 시 실패한다.") + @Test + void pointModel_whenPointIsLessThan0() { + UserModel user = new UserModel(new UserId("user123"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01")); + + CoreException result = assertThrows(CoreException.class, () -> { + new PointModel(user, new Money(-1)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).isEqualTo("가격은 0 이상이어야 합니다."); + } + + //포인트 사용하기 + + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java new file mode 100644 index 000000000..d6df530a8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -0,0 +1,103 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.Email; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.common.Money; +import com.loopers.infrastructure.point.PointJpaRepository; +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 org.springframework.boot.test.mock.mockito.SpyBean; + +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 PointServiceIntegrationTest { + @Autowired + private PointService pointService; + + @Autowired + private PointJpaRepository pointJpaRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @SpyBean + private PointRepository pointRepository; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("포인트 조회") + @Nested + class GetPoint { + @DisplayName("해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다.") + @Test + void returnsPoint_whenValidUserIdIsProvided() { + // arrange + UserModel user = new UserModel(new UserId("userId"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01")); + userRepository.save(user); + PointModel pointModel = new PointModel(user, new Money(10)); + pointService.charge(pointModel); + + // act + PointModel result = pointService.findPoint(pointModel); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getPoint().value()).isEqualTo(10) + ); + } + + @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.") + @Test + void returnsNull_whenInvalidUserIdIsProvided() { + // arrange + UserModel user = new UserModel(new UserId("notUserId1"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01")); + PointModel pointModel = new PointModel(user, new Money(10)); + + // act + PointModel result = pointService.findPoint(pointModel); + + // assert + assertAll( + () -> assertThat(result).isNull() + ); + } + + } + + @DisplayName("포인트 충전") + @Nested + class ChargePoint { + @DisplayName("존재하지 않는 유저 ID 로 충전을 시도한 경우, 실패한다.") + @Test + void throwsException_whenInvalidUserIdIsProvided() { + // arrange + UserModel user = new UserModel(new UserId("notUserId1"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01")); + PointModel pointModel = new PointModel(user, new Money(10)); + + // assert + assertThrows(CoreException.class, () -> pointService.charge(pointModel)); + } + + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java new file mode 100644 index 000000000..b05ae7376 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,104 @@ +package com.loopers.domain.product; + +import com.loopers.domain.common.Quantity; +import com.loopers.domain.common.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 ProductModelTest { + @DisplayName("상품 모델을 생성할 때, ") + @Nested + class Create { + + @DisplayName("상품 재고는 0 이상이어야 한다.") + @Test + void productModel_whenCreateQuantityIsLessThan0() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> { + new Quantity(-1); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품 재고를 차감할 때, 재고가 부족하면 BAD_REQUEST 예외가 발생한다.") + @Test + void productModel_whenDecreaseQuantityIsLessThan0() { + // arrange + ProductModel product = new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseQuantity(new Quantity(11)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("재고가 부족합니다"); + } + + @DisplayName("상품 재고를 정상적으로 차감한다") + @Test + void decreasesQuantity_whenValidQuantityIsProvided() { + // arrange + ProductModel product = new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10)); + int initialQuantity = product.getQuantity().quantity(); + + // act + product.decreaseQuantity(new Quantity(3)); + + // assert + assertThat(product.getQuantity().quantity()).isEqualTo(initialQuantity - 3); + } + + @DisplayName("상품 재고를 0까지 차감할 수 있다") + @Test + void decreasesQuantityToZero_whenQuantityEqualsStock() { + // arrange + ProductModel product = new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10)); + + // act + product.decreaseQuantity(new Quantity(10)); + + // assert + assertThat(product.getQuantity().quantity()).isEqualTo(0); + } + + @DisplayName("상품 등록 시 브랜드가 빈칸이면 BAD_REQUEST 예외가 발생한다.") + @Test + void productModel_whenCreateBrandIsBlank() { + // arrange & act + CoreException result = assertThrows(CoreException.class, () -> { + new Brand(""); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품 등록 시 이름이 빈칸이면 BAD_REQUEST 예외가 발생한다.") + @Test + void productModel_whenCreateNameIsBlank() { + // arrange + String name = ""; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new ProductModel(name, new Brand("Apple"), new Money(10000), new Quantity(10)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..ea42e67cf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,75 @@ +package com.loopers.domain.product; + +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.common.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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.springframework.data.domain.Pageable; + +@SpringBootTest +class ProductServiceIntegrationTest { + @Autowired + private ProductService productService; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("상품를 조회할 때,") + @Nested + class Get { + + @DisplayName("상품 다건 조회 시 상품이 없으면 NOT_FOUND 예외가 발생한다.") + @Test + void productService_whenGetProductsIsNotFound() { + // arrange + productJpaRepository.save(new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10))); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.getProducts(Pageable.ofSize(10), "latest", "Apple"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("상품 단건 조회 시 상품이 없으면 NOT_FOUND 예외가 발생한다.") + @Test + void productService_whenGetProductIsNotFound() { + // arrange + Long id = 1L; + productJpaRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다.")); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.getProduct(id); + }); + + // assert + assertAll( + () -> assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java new file mode 100644 index 000000000..915cacff5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -0,0 +1,121 @@ +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.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserModelTest { + @DisplayName("회원가입 시 User 객체를 생성할 때, ") + @Nested + class Create { + + /* + - [ ] ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다. + - [ ] 이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다. + - [ ] 생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다. + */ + + //입력한 아이디가 빈칸칸이거나 공백이면, User 객체 생성에 실패한다. + @DisplayName("입력한 ID 가 비어있으면, User 객체 생성에 실패한다.") + @Test + void createsUserModel_whenUserIdIsBlank() { + // arrange + String userId = " "; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(new UserId(userId), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01")); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @DisplayName("ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다.") + @Test + void createUserModel_whenUserIdIsNotValid() { + // arrange + String userId = "user123123123"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(new UserId(userId), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01")); + }); + + //assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + } + + @DisplayName("입력한 이메일이 비어있으면, User 객체 생성에 실패한다.") + @Test + void createsUserModel_whenEmailIsBlank() { + // arrange + String email = " "; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(new UserId("userId"), new Email(email), new Gender("male"), new BirthDate("1999-01-01")); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다.") + @Test + void createUserModel_whenEmailIsNotValid() { + // arrange + String email = "user123123123"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(new UserId("user123"), new Email(email), new Gender("male"), new BirthDate("1999-01-01")); + }); + + //assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + } + + @DisplayName("생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다.") + @Test + void createUserModel_whenBirthDateIsNotValid() { + // arrange + String birthDate = "19991-01-01"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate(birthDate)); + }); + + //assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + } + + + //입력한 생년월일이 빈칸이거나 공백이면, User 객체 생성에 실패한다. + @DisplayName("입력한 생년월일이 null이면, User 객체 생성에 실패한다.") + @Test + void createsUserModel_whenBirthDateIsNull() { + // arrange + String birthDate = null; + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(new UserId("userId"), new Email("user123@example.com"), new Gender("male"), new BirthDate(birthDate)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 000000000..a9e837172 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,129 @@ +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; +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.mock.mockito.SpyBean; +import com.loopers.infrastructure.user.UserRepositoryImpl; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; + +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 UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @SpyBean + private UserRepositoryImpl userRepositoryImpl; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + /* + * - [ ] 회원 가입시 User 저장이 수행된다. ( spy 검증 ) + * - [ ] 이미 가입된 ID 로 회원가입 시도 시, 실패한다. + * - [ ] 해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다. + * - [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. + */ + + @DisplayName("회원가입") + @Nested + class SignUp { + + @DisplayName("회원 가입시 User 저장이 수행된다. ( spy 검증 )") + @Test + void returnsUserInfo_whenSignUp() { + // arrange + UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + + // act + UserModel user = userService.signUp(userModel); + + // assert + + verify(userRepositoryImpl, times(1)).save(any(UserModel.class)); + + assertAll( + () -> assertThat(user).isNotNull(), + () -> assertThat(user.getUserId()).isNotNull(), + () -> assertThat(user.getUserId()).isEqualTo("userId1"), + () -> assertThat(user.getEmail()).isEqualTo("user123@user.com"), + () -> assertThat(user.getBirthDate()).isEqualTo("1999-01-01")); + + } + + @DisplayName("이미 가입된 ID 로 회원가입 시도 시, 실패한다.") + @Test + void throwsException_whenUserIdIsDuplicated() { + // arrange + UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + userService.signUp(userModel); + + UserModel dupUserModel = new UserModel(new UserId("userId1"), new Email("user1234@user.com"), new Gender("male"), new BirthDate("1999-01-11")); + + // act + CoreException exception = assertThrows(CoreException.class, () -> userService.signUp(dupUserModel)); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("내 정보 조회") + @Nested + class MyPage { + @DisplayName("해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.") + @Test + void returnsUserInfo_whenValidIdIsProvided() { + // arrange + UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + userService.signUp(userModel); + + // act + UserModel result = userService.getUser(userModel.getUserId()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getUserId()).isEqualTo(userModel.getUserId()), + () -> assertThat(result.getEmail()).isEqualTo(userModel.getEmail()), + () -> assertThat(result.getBirthDate()).isEqualTo(userModel.getBirthDate()) + ); + } + + @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.") + @Test + void returnsNull_whenInvalidUserIdIsProvided() { + // arrange + UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + + // act + UserModel result = userService.getUser(userModel.getUserId()); + + // assert + assertThat(result).isNull(); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java new file mode 100644 index 000000000..47a8d8914 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java @@ -0,0 +1,168 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.point.PointModel; +import com.loopers.domain.point.PointRepository; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; +import com.loopers.domain.user.Gender; +import com.loopers.interfaces.api.point.PointV1Dto; +import com.loopers.domain.common.Money; +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.*; + +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 PointV1ApiE2ETest { + + private static final String ENDPOINT_GET = "/api/v1/points"; + private static final String ENDPOINT_CHARGE = "/api/v1/points/charge"; + + private final TestRestTemplate testRestTemplate; + private final UserRepository userRepository; + private final PointRepository pointRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public PointV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserRepository userRepository, + PointRepository pointRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userRepository = userRepository; + this.pointRepository = pointRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + /* + 포인트 조회 + - [x] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. + - [x] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. + + 포인트 충전 + - [x] 존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다. + - [x] 존재하지 않는 유저로 요청할 경우, `404 Not Found` 응답을 반환한다. + */ + + @DisplayName("GET /api/v1/points") + @Nested + class GetPoint { + @DisplayName("포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다.") + @Test + void returnsPoint_whenValidUserIdHeaderIsProvided() { + // arrange + UserModel user = userRepository.save( + new UserModel(new UserId("user123"), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + pointRepository.save(new PointModel(user, new Money(500))); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", user.getUserId().userId()); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_GET, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId().userId()), + () -> assertThat(response.getBody().data().point().value()).isEqualTo(500) + ); + } + + @DisplayName("`X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.") + @Test + void throwsBadRequest_whenUserIdHeaderIsMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + // X-USER-ID 헤더를 의도적으로 설정하지 않음 + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_GET, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @DisplayName("POST /api/v1/points/charge") + @Nested + class ChargePoint { + @DisplayName("존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다.") + @Test + void chargesPoint_when1000AmountIsProvided() { + // arrange + UserModel user = userRepository.save( + new UserModel(new UserId("user123"), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + PointV1Dto.ChargeRequest request = new PointV1Dto.ChargeRequest(new Money(1000)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", user.getUserId().userId()); + headers.setContentType(MediaType.APPLICATION_JSON); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId().userId()), + () -> assertThat(response.getBody().data().point().value()).isEqualTo(1000) + ); + } + + @DisplayName("존재하지 않는 유저로 요청할 경우, `404 Not Found` 응답을 반환한다.") + @Test + void throwsNotFoundException_whenUserDoesNotExist() { + // arrange + PointV1Dto.ChargeRequest request = new PointV1Dto.ChargeRequest(new Money(1000)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", "nonexistent"); + headers.setContentType(MediaType.APPLICATION_JSON); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 000000000..f88d29e26 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,165 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.user.Email; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; +import com.loopers.domain.user.Gender; +import com.loopers.interfaces.api.user.UserV1Dto; +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.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.function.Function; + +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 UserV1ApiE2ETest { + + private static final String ENDPOINT_SIGNUP = "/api/v1/users/signup"; + private static final Function ENDPOINT_GET = userId -> "/api/v1/users/" + userId; + + private final TestRestTemplate testRestTemplate; + private final UserRepository userRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserRepository userRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userRepository = userRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + /* + 회원가입 + - [x] 회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다. + - [x] 회원 가입 시에 필수 필드가 없을 경우, `400 Bad Request` 응답을 반환한다. + + 내 정보 조회 + - [x] 내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다. + - [x] 존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다. + */ + + @DisplayName("POST /api/v1/users/signup") + @Nested + class Signup { + @DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.") + @Test + void returnsUserInfo_whenSignupIsSuccessful() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "user123", + "user123@example.com", + "male", + "1999-01-01" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo("user123"), + () -> assertThat(response.getBody().data().email()).isEqualTo("user123@example.com"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("1999-01-01") + ); + } + + @DisplayName("회원 가입 시에 성별이 없을 경우, 400 Bad Request 응답을 반환한다.") + @Test + void throwsBadRequest_whenRequiredFieldIsMissing() { + // arrange - gender 필드를 null로 설정 + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "user123", + "user123@example.com", + null, + "1999-01-01" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @DisplayName("GET /api/v1/users/{userId}") + @Nested + class GetUser { + @DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다.") + @Test + void returnsUserInfo_whenValidUserIdIsProvided() { + // arrange + UserModel userModel = userRepository.save( + new UserModel(new UserId("user123"), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + String requestUrl = ENDPOINT_GET.apply(userModel.getUserId().userId()); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo(userModel.getUserId().userId()), + () -> assertThat(response.getBody().data().email()).isEqualTo(userModel.getEmail().email()), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo(userModel.getBirthDate().birthDate()) + ); + } + + @DisplayName("존재하지 않는 ID 로 조회할 경우, 404 Not Found 응답을 반환한다.") + @Test + void throwsNotFoundException_whenUserIdDoesNotExist() { + // arrange + String invalidUserId = "nonexistent"; + String requestUrl = ENDPOINT_GET.apply(invalidUserId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +}