diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..80063b584 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,26 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class BrandFacade { + + private final BrandService brandService; + + @Transactional + public BrandInfo register(String name, String description, String imageUrl) { + BrandModel brand = brandService.register(name, description, imageUrl); + return BrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public BrandInfo getById(Long id) { + BrandModel brand = brandService.getById(id); + return BrandInfo.from(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..091b2e6e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,19 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; + +public record BrandInfo( + Long id, + String name, + String description, + String imageUrl +) { + public static BrandInfo from(BrandModel model) { + return new BrandInfo( + model.getId(), + model.getName(), + model.getDescription(), + model.getImageUrl() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} 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..768ed366a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,37 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final ProductLikeService productLikeService; + private final MemberService memberService; + private final ProductService productService; + + @Transactional + public void addLike(String loginId, String password, Long productId) { + MemberModel member = memberService.getMyInfo(loginId, password); + productService.getById(productId); + productLikeService.addLike(member.getId(), productId); + } + + @Transactional + public void removeLike(String loginId, String password, Long productId) { + MemberModel member = memberService.getMyInfo(loginId, password); + productService.getById(productId); + productLikeService.removeLike(member.getId(), productId); + } + + @Transactional(readOnly = true) + public long countByProductId(Long productId) { + return productLikeService.countByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java new file mode 100644 index 000000000..51eef0b85 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberFacade.java @@ -0,0 +1,44 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.point.PointModel; +import com.loopers.domain.point.PointService; +import com.loopers.domain.vo.Money; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberFacade { + + private final MemberService memberService; + private final PointService pointService; + + @Transactional + public void register(String loginId, String password, String name, LocalDate birthDate, String email) { + MemberModel member = memberService.register(loginId, password, name, birthDate, email); + pointService.createPoint(member.getId()); + } + + @Transactional(readOnly = true) + public MemberInfo getMyInfo(String loginId, String password) { + MemberModel member = memberService.getMyInfo(loginId, password); + PointModel point = pointService.getByMemberId(member.getId()); + return MemberInfo.of(member, point.getBalanceMoney()); + } + + @Transactional + public void chargePoint(String loginId, String password, long amount) { + MemberModel member = memberService.getMyInfo(loginId, password); + pointService.charge(member.getId(), Money.of(amount)); + } + + @Transactional + public void changePassword(String loginId, String currentPassword, String newPassword) { + memberService.changePassword(loginId, currentPassword, newPassword); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java new file mode 100644 index 000000000..86561a5a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/MemberInfo.java @@ -0,0 +1,24 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.vo.Money; + +import java.time.LocalDate; + +public record MemberInfo( + String loginId, + String maskedName, + LocalDate birthDate, + String email, + Money point +) { + public static MemberInfo of(MemberModel model, Money point) { + return new MemberInfo( + model.getLoginId(), + model.getMaskedName(), + model.getBirthDate(), + model.getEmail(), + point + ); + } +} 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..ec67eea40 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,86 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.vo.Money; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final MemberService memberService; + private final ProductService productService; + private final BrandService brandService; + private final PointService pointService; + + @Transactional + public OrderInfo createOrder(String loginId, String password, List itemRequests) { + // 1. 회원 인증 + MemberModel member = memberService.getMyInfo(loginId, password); + + // 2. 주문 항목 생성 (상품 스냅샷 + 재고 차감) + List orderItems = new ArrayList<>(); + for (OrderItemRequest req : itemRequests) { + ProductModel product = productService.getById(req.productId()); + productService.decreaseStock(product.getId(), req.quantity()); + + OrderItemModel item = new OrderItemModel( + product.getId(), + product.getName(), + brandService.getById(product.getBrandId()).getName(), + product.getPrice(), + req.quantity() + ); + orderItems.add(item); + } + + // 3. 주문 생성 + OrderModel order = orderService.createOrder(member.getId(), orderItems); + + // 4. 포인트 차감 + pointService.use(member.getId(), Money.of(order.getTotalAmount())); + + List savedItems = orderService.getOrderItems(order.getId()); + return OrderInfo.from(order, savedItems); + } + + @Transactional(readOnly = true) + public OrderInfo getById(Long id) { + OrderModel order = orderService.getById(id); + List items = orderService.getOrderItems(order.getId()); + return OrderInfo.from(order, items); + } + + @Transactional(readOnly = true) + public List getByMember(String loginId, String password) { + MemberModel member = memberService.getMyInfo(loginId, password); + List orders = orderService.getByMemberId(member.getId()); + + return orders.stream() + .map(order -> { + List items = orderService.getOrderItems(order.getId()); + return OrderInfo.from(order, items); + }) + .toList(); + } + + public record OrderItemRequest( + Long productId, + int quantity + ) { + } +} 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..2101a8831 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,42 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; + +import java.util.List; + +public record OrderInfo( + Long id, + Long memberId, + int totalAmount, + List items +) { + public record OrderItemInfo( + Long productId, + String productName, + String brandName, + int productPrice, + int quantity, + int totalAmount + ) { + public static OrderItemInfo from(OrderItemModel model) { + return new OrderItemInfo( + model.getProductId(), + model.getProductName(), + model.getBrandName(), + model.getProductPrice(), + model.getQuantity(), + model.getTotalAmount() + ); + } + } + + public static OrderInfo from(OrderModel order, List items) { + return new OrderInfo( + order.getId(), + order.getMemberId(), + order.getTotalAmount(), + items.stream().map(OrderItemInfo::from).toList() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java new file mode 100644 index 000000000..7bffabeba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -0,0 +1,28 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.ProductModel; + +public record ProductDetailInfo( + Long id, + String name, + String description, + int price, + int stockQuantity, + String imageUrl, + String brandName, + long likeCount +) { + public static ProductDetailInfo of(ProductModel product, BrandModel brand, long likeCount) { + return new ProductDetailInfo( + product.getId(), + product.getName(), + product.getDescription(), + product.getPrice(), + product.getStockQuantity(), + product.getImageUrl(), + brand.getName(), + likeCount + ); + } +} 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..75e4ba73a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,59 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + private final ProductLikeService productLikeService; + + @Transactional + public ProductInfo register(Long brandId, String name, String description, int price, int stockQuantity, String imageUrl) { + brandService.getById(brandId); + ProductModel product = productService.register(brandId, name, description, price, stockQuantity, imageUrl); + return ProductInfo.from(product); + } + + @Transactional(readOnly = true) + public ProductDetailInfo getById(Long id) { + ProductModel product = productService.getById(id); + BrandModel brand = brandService.getById(product.getBrandId()); + long likeCount = productLikeService.countByProductId(id); + return ProductDetailInfo.of(product, brand, likeCount); + } + + @Transactional(readOnly = true) + public List getProducts(ProductSortType sortType) { + List products = productService.getAll(); + return buildAndSort(products, sortType); + } + + @Transactional(readOnly = true) + public List getProductsByBrandId(Long brandId, ProductSortType sortType) { + List products = productService.getByBrandId(brandId); + return buildAndSort(products, sortType); + } + + private List buildAndSort(List products, ProductSortType sortType) { + return products.stream() + .map(product -> { + BrandModel brand = brandService.getById(product.getBrandId()); + long likeCount = productLikeService.countByProductId(product.getId()); + return ProductDetailInfo.of(product, brand, likeCount); + }) + .sorted(sortType.getComparator()) + .toList(); + } +} 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..43d3c4911 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; + +public record ProductInfo( + Long id, + Long brandId, + String name, + String description, + int price, + int stockQuantity, + String imageUrl +) { + public static ProductInfo from(ProductModel model) { + return new ProductInfo( + model.getId(), + model.getBrandId(), + model.getName(), + model.getDescription(), + model.getPrice(), + model.getStockQuantity(), + model.getImageUrl() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSortType.java new file mode 100644 index 000000000..84232af73 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSortType.java @@ -0,0 +1,22 @@ +package com.loopers.application.product; + +import lombok.Getter; + +import java.util.Comparator; + +@Getter +public enum ProductSortType { + + LATEST("최신순", Comparator.comparing(ProductDetailInfo::id).reversed()), + PRICE_ASC("낮은 가격순", Comparator.comparing(ProductDetailInfo::price)), + LIKES_DESC("좋아요 많은순", Comparator.comparing(ProductDetailInfo::likeCount).reversed()); + + private final String description; + private final Comparator comparator; + + ProductSortType(String description, Comparator comparator) { + this.description = description; + this.comparator = comparator; + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java new file mode 100644 index 000000000..1d6f96b32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -0,0 +1,36 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "brand") +public class BrandModel extends BaseEntity { + + @Column(nullable = false, length = 50) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(length = 500) + private String imageUrl; + + protected BrandModel() { + } + + public BrandModel(String name, String description, String imageUrl) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); + } + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..e22838b12 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.brand; + +import java.util.Optional; + +public interface BrandRepository { + BrandModel save(BrandModel brandModel); + Optional findById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..f751ed41b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,25 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public BrandModel register(String name, String description, String imageUrl) { + return brandRepository.save(new BrandModel(name, description, imageUrl)); + } + + @Transactional(readOnly = true) + public BrandModel getById(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e5662..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -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; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java new file mode 100644 index 000000000..6a40a2b50 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java @@ -0,0 +1,37 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +@Entity +@Table(name = "product_like", uniqueConstraints = { + @UniqueConstraint(columnNames = {"member_id", "product_id"}) +}) +@Getter +public class ProductLikeModel extends BaseEntity { + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false) + private Long productId; + + protected ProductLikeModel() {} + + public ProductLikeModel(Long memberId, Long productId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 필수입니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + this.memberId = memberId; + this.productId = productId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java new file mode 100644 index 000000000..746b23e35 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +public interface ProductLikeRepository { + ProductLikeModel save(ProductLikeModel like); + void delete(ProductLikeModel like); + Optional findByMemberIdAndProductId(Long memberId, Long productId); + long countByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java new file mode 100644 index 000000000..46b810747 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java @@ -0,0 +1,37 @@ +package com.loopers.domain.like; + +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; + +@Component +@RequiredArgsConstructor +public class ProductLikeService { + + private final ProductLikeRepository productLikeRepository; + + @Transactional + public void addLike(Long memberId, Long productId) { + productLikeRepository.findByMemberIdAndProductId(memberId, productId) + .ifPresent(like -> { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다."); + }); + + productLikeRepository.save(new ProductLikeModel(memberId, productId)); + } + + @Transactional + public void removeLike(Long memberId, Long productId) { + ProductLikeModel like = productLikeRepository.findByMemberIdAndProductId(memberId, productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "좋아요하지 않은 상품입니다.")); + + productLikeRepository.delete(like); + } + + @Transactional(readOnly = true) + public long countByProductId(Long productId) { + return productLikeRepository.countByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java new file mode 100644 index 000000000..100fef984 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberModel.java @@ -0,0 +1,75 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Entity +@Table(name = "member") +@Getter +public class MemberModel extends BaseEntity { + + private String loginId; + private String password; + private String name; + private LocalDate birthDate; + private String email; + + protected MemberModel() {} + + public MemberModel(String loginId, String password, String name, LocalDate birthDate, String email) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "loginId는 비어있을 수 없습니다."); + } + if (!loginId.matches("^[a-zA-Z0-9]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "loginId는 영문과 숫자만 허용됩니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + if (birthDate == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + } + validatePassword(password, birthDate); + if (email == null || !email.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); + } + + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public String getMaskedName() { + return name.substring(0, name.length() - 1) + "*"; + } + + public void changePassword(String newPassword) { + validatePassword(newPassword, this.birthDate); + this.password = newPassword; + } + + public void applyEncodedPassword(String encodedPassword) { + this.password = encodedPassword; + } + + private static void validatePassword(String password, LocalDate birthDate) { + if (password == null || password.length() < 8 || password.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자여야 합니다."); + } + if (!password.matches("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 영문 대소문자, 숫자, 특수문자만 사용 가능합니다."); + } + if (password.contains(birthDate.format(DateTimeFormatter.BASIC_ISO_DATE))) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 000000000..9c34afedf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + MemberModel save(MemberModel member); + + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java new file mode 100644 index 000000000..665bc5441 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,51 @@ +package com.loopers.domain.member; + +import com.loopers.support.crypto.PasswordEncoder; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@RequiredArgsConstructor +@Component +public class MemberService { + + private final MemberRepository memberRepository; + + @Transactional + public MemberModel register(String loginId, String password, String name, LocalDate birthDate, String email) { + memberRepository.findByLoginId(loginId) + .ifPresent(member -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 loginId입니다."); + }); + + MemberModel member = new MemberModel(loginId, password, name, birthDate, email); + member.applyEncodedPassword(PasswordEncoder.encode(password)); + return memberRepository.save(member); + } + + @Transactional(readOnly = true) + public MemberModel getMyInfo(String loginId, String password) { + MemberModel member = memberRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 회원입니다.")); + + if (!PasswordEncoder.matches(password, member.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다."); + } + + return member; + } + + @Transactional + public void changePassword(String loginId, String currentPassword, String newPassword) { + if (currentPassword.equals(newPassword)) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."); + } + MemberModel member = getMyInfo(loginId, currentPassword); + member.changePassword(newPassword); + member.applyEncodedPassword(PasswordEncoder.encode(newPassword)); + } +} 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..def18d269 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -0,0 +1,55 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "order_item") +@Getter +public class OrderItemModel extends BaseEntity { + + private Long orderId; + private Long productId; + private String productName; + private String brandName; + private int productPrice; + private int quantity; + + protected OrderItemModel() { + } + + public OrderItemModel(Long productId, String productName, String brandName, int productPrice, int quantity) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 비어있을 수 없습니다."); + } + if (productPrice < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 0 이상이어야 합니다."); + } + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 1 이상이어야 합니다."); + } + this.productId = productId; + this.productName = productName; + this.brandName = brandName; + this.productPrice = productPrice; + this.quantity = quantity; + } + + public void assignOrderId(Long orderId) { + this.orderId = orderId; + } + + public int getTotalAmount() { + return productPrice * quantity; + } +} 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..d5306e8f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,37 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.util.List; + +@Entity +@Table(name = "orders") +@Getter +public class OrderModel extends BaseEntity { + + private Long memberId; + private int totalAmount; + private String status; + + protected OrderModel() { + } + + public OrderModel(Long memberId, List orderItems) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 필수입니다."); + } + if (orderItems == null || orderItems.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다."); + } + this.memberId = memberId; + this.totalAmount = orderItems.stream() + .mapToInt(OrderItemModel::getTotalAmount) + .sum(); + this.status = "CREATED"; + } +} 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..42fca2af8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.order; + +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + OrderModel save(OrderModel order); + Optional findById(Long id); + List findAllByMemberId(Long memberId); + OrderItemModel saveItem(OrderItemModel orderItem); + List findItemsByOrderId(Long orderId); +} 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..ab62adef6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,45 @@ +package com.loopers.domain.order; + +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; + +@Component +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + + @Transactional + public OrderModel createOrder(Long memberId, List orderItems) { + OrderModel order = new OrderModel(memberId, orderItems); + OrderModel saveOrder = orderRepository.save(order); + + for (OrderItemModel item : orderItems) { + item.assignOrderId(saveOrder.getId()); + orderRepository.saveItem(item); + } + + return saveOrder; + } + + @Transactional(readOnly = true) + public OrderModel getById(Long id) { + return orderRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다.")); + } + + @Transactional(readOnly = true) + public List getByMemberId(Long memberId) { + return orderRepository.findAllByMemberId(memberId); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long orderId) { + return orderRepository.findItemsByOrderId(orderId); + } +} 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..1c37cc378 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java @@ -0,0 +1,55 @@ +package com.loopers.domain.point; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.vo.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.math.BigDecimal; + +@Entity +@Table(name = "point") +@Getter +public class PointModel extends BaseEntity { + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false) + private BigDecimal balance; + + protected PointModel() {} + + public PointModel(Long memberId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 필수입니다."); + } + this.memberId = memberId; + this.balance = BigDecimal.ZERO; + } + + public Money getBalanceMoney() { + return new Money(this.balance); + } + + public void charge(Money amount) { + if (!amount.isGreaterThanOrEqual(Money.of(1))) { + throw new CoreException(ErrorType.BAD_REQUEST, "충전 금액은 1원 이상이어야 합니다."); + } + this.balance = this.balance.add(amount.amount()); + } + + public void use(Money amount) { + if (!amount.isGreaterThanOrEqual(Money.of(1))) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액은 1원 이상이어야 합니다."); + } + if (!getBalanceMoney().isGreaterThanOrEqual(amount)) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트가 부족합니다."); + } + this.balance = this.balance.subtract(amount.amount()); + } +} 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..59b6830c1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.point; + +import java.util.Optional; + +public interface PointRepository { + PointModel save(PointModel point); + + Optional findByMemberId(Long memberId); +} 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..b26a4e2d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -0,0 +1,40 @@ +package com.loopers.domain.point; + +import com.loopers.domain.vo.Money; +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; + +@RequiredArgsConstructor +@Component +public class PointService { + + private final PointRepository pointRepository; + + @Transactional + public PointModel createPoint(Long memberId) { + PointModel point = new PointModel(memberId); + return pointRepository.save(point); + } + + @Transactional(readOnly = true) + public PointModel getByMemberId(Long memberId) { + return pointRepository.findByMemberId(memberId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트 정보가 존재하지 않습니다.")); + } + + @Transactional + public void charge(Long memberId, Money amount) { + PointModel point = getByMemberId(memberId); + point.charge(amount); + } + + @Transactional + public void use(Long memberId, Money amount) { + PointModel point = getByMemberId(memberId); + point.use(amount); + } + +} 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..cd4960698 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,74 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "product") +@Getter +public class ProductModel extends BaseEntity { + + @Column(nullable = false) + private Long brandId; + + @Column(nullable = false, length = 50) + private String name; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(nullable = false) + private int price; + + @Column(nullable = false) + private int stockQuantity; + + @Column(length = 500) + private String imageUrl; + + protected ProductModel() {} + + public ProductModel(Long brandId, String name, String description, int price, int stockQuantity, String imageUrl) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + if (stockQuantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + + this.brandId = brandId; + this.name = name; + this.description = description; + this.price = price; + this.stockQuantity = stockQuantity; + this.imageUrl = imageUrl; + } + + public void decreaseStock(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다."); + } + if (this.stockQuantity < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.stockQuantity -= quantity; + } + + public void increaseStock(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "증가 수량은 1 이상이어야 합니다."); + } + this.stockQuantity += 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..5f9f1c787 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + ProductModel save(ProductModel product); + Optional findById(Long id); + List findAllByBrandId(Long brandId); + List findAll(); +} 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..2163943b0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,45 @@ +package com.loopers.domain.product; + +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; + +@Component +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + @Transactional + public ProductModel register(Long brandId, String name, String description, int price, int stockQuantity, String imageUrl) { + ProductModel product = new ProductModel(brandId, name, description, price, stockQuantity, imageUrl); + return productRepository.save(product); + } + + @Transactional(readOnly = true) + public ProductModel getById(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + } + + @Transactional(readOnly = true) + public List getByBrandId(Long brandId) { + return productRepository.findAllByBrandId(brandId); + } + + @Transactional(readOnly = true) + public List getAll() { + return productRepository.findAll(); + } + + @Transactional + public void decreaseStock(Long productId, int quantity) { + ProductModel product = getById(productId); + product.decreaseStock(quantity); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/vo/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/vo/Money.java new file mode 100644 index 000000000..025637cb5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/vo/Money.java @@ -0,0 +1,36 @@ +package com.loopers.domain.vo; + +import java.math.BigDecimal; + +public record Money(BigDecimal amount) { + + public Money { + if (amount == null) { + throw new IllegalArgumentException("금액은 null일 수 없습니다."); + } + if (amount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("금액은 0 이상이어야 합니다."); + } + } + + public Money plus(Money other) { + return new Money(this.amount.add(other.amount)); + } + + public Money minus(Money other) { + return new Money(this.amount.subtract(other.amount)); + } + + public boolean isGreaterThanOrEqual(Money other) { + return this.amount.compareTo(other.amount) >= 0; + } + + public static Money of(long value) { + return new Money(BigDecimal.valueOf(value)); + } + + public static Money zero() { + return new Money(BigDecimal.ZERO); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..984d9fd2c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BrandJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..a1b07a010 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public BrandModel save(BrandModel brandModel) { + return brandJpaRepository.save(brandModel); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java new file mode 100644 index 000000000..eeaa9ab32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.ProductLikeModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ProductLikeJpaRepository extends JpaRepository { + Optional findByMemberIdAndProductId(Long memberId, Long productId); + long countByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java new file mode 100644 index 000000000..de01b7a12 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.ProductLikeModel; +import com.loopers.domain.like.ProductLikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class ProductLikeRepositoryImpl implements ProductLikeRepository { + + private final ProductLikeJpaRepository productLikeJpaRepository; + + @Override + public ProductLikeModel save(ProductLikeModel like) { + return productLikeJpaRepository.save(like); + } + + @Override + public void delete(ProductLikeModel like) { + productLikeJpaRepository.delete(like); + } + + @Override + public Optional findByMemberIdAndProductId(Long memberId, Long productId) { + return productLikeJpaRepository.findByMemberIdAndProductId(memberId, productId); + } + + @Override + public long countByProductId(Long productId) { + return productLikeJpaRepository.countByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 000000000..b6839941b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 000000000..ac2554230 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public MemberModel save(MemberModel member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId(loginId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..7dbca2e8d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderItemJpaRepository extends JpaRepository { + List findAllByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..15d594f32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,10 @@ +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 findAllByMemberId(Long memberId); +} 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..5438510cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public OrderModel save(OrderModel order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public List findAllByMemberId(Long memberId) { + return orderJpaRepository.findAllByMemberId(memberId); + } + + @Override + public OrderItemModel saveItem(OrderItemModel orderItem) { + return orderItemJpaRepository.save(orderItem); + } + + @Override + public List findItemsByOrderId(Long orderId) { + return orderItemJpaRepository.findAllByOrderId(orderId); + } +} 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..7ef985322 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.point; + +import com.loopers.domain.point.PointModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PointJpaRepository extends JpaRepository { + Optional findByMemberId(Long memberId); +} 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..b6e7511fb --- /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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class PointRepositoryImpl implements PointRepository { + + private final PointJpaRepository pointJpaRepository; + + @Override + public PointModel save(PointModel point) { + return pointJpaRepository.save(point); + } + + @Override + public Optional findByMemberId(Long memberId) { + return pointJpaRepository.findByMemberId(memberId); + } +} 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..1e52e2e87 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ProductJpaRepository extends JpaRepository { + List findAllByBrandId(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..af45a4483 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public ProductModel save(ProductModel product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandId(brandId); + } + + @Override + public List findAll() { + return productJpaRepository.findAll(); + } +} 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..2c5e7bdc6 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,16 +8,14 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +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; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import org.springframework.web.server.ServerWebInputException; import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.Arrays; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; @RestControllerAdvice @@ -46,6 +44,13 @@ 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(HttpMessageNotReadableException e) { String errorMessage; @@ -91,17 +96,6 @@ public ResponseEntity> handleBadRequest(HttpMessageNotReadableExc return failureResponse(ErrorType.BAD_REQUEST, errorMessage); } - @ExceptionHandler - public ResponseEntity> handleBadRequest(ServerWebInputException e) { - String missingParams = extractMissingParameter(e.getReason() != null ? e.getReason() : ""); - if (!missingParams.isEmpty()) { - String message = String.format("필수 요청 값 '%s'가 누락되었습니다.", missingParams); - return failureResponse(ErrorType.BAD_REQUEST, message); - } else { - return failureResponse(ErrorType.BAD_REQUEST, null); - } - } - @ExceptionHandler public ResponseEntity> handleNotFound(NoResourceFoundException e) { return failureResponse(ErrorType.NOT_FOUND, null); @@ -113,12 +107,6 @@ public ResponseEntity> handle(Throwable e) { return failureResponse(ErrorType.INTERNAL_ERROR, null); } - private String extractMissingParameter(String message) { - Pattern pattern = Pattern.compile("'(.+?)'"); - Matcher matcher = pattern.matcher(message); - return matcher.find() ? matcher.group(1) : ""; - } - private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) { return ResponseEntity.status(errorType.getStatus()) .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..c49b9ef59 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand V1 API", description = "브랜드 API 입니다.") +public interface BrandV1ApiSpec { + + @Operation(summary = "브랜드 등록", description = "새로운 브랜드를 등록합니다.") + ApiResponse register(BrandV1Dto.RegisterRequest request); + + @Operation(summary = "브랜드 조회", description = "ID로 브랜드를 조회합니다.") + ApiResponse getById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..94c72500f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @PostMapping + @Override + public ApiResponse register( + @RequestBody BrandV1Dto.RegisterRequest request + ) { + BrandInfo info = brandFacade.register(request.name(), request.description(), request.imageUrl()); + return ApiResponse.success( + new BrandV1Dto.BrandResponse( + info.id(), info.name(), info.description(), info.imageUrl() + ) + ); + } + + @GetMapping("/{id}") + @Override + public ApiResponse getById( + @PathVariable Long id + ) { + BrandInfo info = brandFacade.getById(id); + return ApiResponse.success( + new BrandV1Dto.BrandResponse( + info.id(), info.name(), info.description(), info.imageUrl() + ) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..741758003 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.brand; + +public class BrandV1Dto { + + // 브랜드 등록 요청 + public record RegisterRequest( + String name, + String description, + String imageUrl + ) { + } + + // 브랜드 응답 + public record BrandResponse( + Long id, + String name, + String description, + String imageUrl + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 917376016..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} 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..6623f5c4d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.like; + +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 = "좋아요 API 입니다.") +public interface LikeV1ApiSpec { + + @Operation(summary = "좋아요 등록", description = "상품에 좋아요를 등록합니다.") + ApiResponse addLike( + @Parameter(description = "로그인 ID") String loginId, + @Parameter(description = "로그인 비밀번호") String loginPw, + @Parameter(description = "상품 ID") Long productId + ); + + @Operation(summary = "좋아요 취소", description = "상품의 좋아요를 취소합니다.") + ApiResponse removeLike( + @Parameter(description = "로그인 ID") String loginId, + @Parameter(description = "로그인 비밀번호") String loginPw, + @Parameter(description = "상품 ID") Long productId + ); + + @Operation(summary = "좋아요 수 조회", description = "상품의 좋아요 수를 조회합니다.") + ApiResponse count(@Parameter(description = "상품 ID") Long productId); +} 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..1aa3e1249 --- /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.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeFacade likeFacade; + + @PostMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse addLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @PathVariable Long productId + ) { + likeFacade.addLike(loginId, loginPw, productId); + return ApiResponse.success(null); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse removeLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @PathVariable Long productId + ) { + likeFacade.removeLike(loginId, loginPw, productId); + return ApiResponse.success(null); + } + + @GetMapping("/api/v1/likes/count") + @Override + public ApiResponse count( + @RequestParam Long productId + ) { + long count = likeFacade.countByProductId(productId); + return ApiResponse.success(new LikeV1Dto.CountResponse(productId, count)); + } +} 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..4742b5616 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.api.like; + +public class LikeV1Dto { + + public record CountResponse( + Long productId, + long count + ) { + + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 000000000..6d7f593d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.member; + +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 = "Member V1 API", description = "회원 API 입니다.") +public interface MemberV1ApiSpec { + + @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") + ApiResponse register(MemberV1Dto.RegisterRequest request); + + @Operation(summary = "내 정보 조회", description = "로그인 정보를 기반으로 내 정보를 조회합니다.") + ApiResponse getMyInfo( + @Parameter(description = "로그인 ID") String loginId, + @Parameter(description = "로그인 비밀번호") String loginPw + ); + + @Operation(summary = "비밀번호 변경", description = "비밀번호를 변경합니다.") + ApiResponse changePassword( + @Parameter(description = "로그인 ID") String loginId, + @Parameter(description = "로그인 비밀번호") String loginPw, + MemberV1Dto.ChangePasswordRequest request + ); + + @Operation(summary = "포인트 충전", description = "회원의 포인트를 충전합니다.") + ApiResponse chargePoint( + @Parameter(description = "로그인 ID") String loginId, + @Parameter(description = "로그인 비밀번호") String loginPw, + MemberV1Dto.ChargePointRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 000000000..ba35abdd0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.member.MemberFacade; +import com.loopers.application.member.MemberInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller implements MemberV1ApiSpec { + + private final MemberFacade memberFacade; + + @PostMapping + @Override + public ApiResponse register(@RequestBody MemberV1Dto.RegisterRequest request) { + memberFacade.register( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(null); + } + + @GetMapping("/me") + @Override + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw + ) { + MemberInfo info = memberFacade.getMyInfo(loginId, loginPw); + MemberV1Dto.MyInfoResponse response = new MemberV1Dto.MyInfoResponse( + info.loginId(), + info.maskedName(), + info.birthDate(), + info.email(), + info.point() + ); + return ApiResponse.success(response); + } + + @PatchMapping("/me/password") + @Override + public ApiResponse changePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @RequestBody MemberV1Dto.ChangePasswordRequest request + ) { + memberFacade.changePassword(loginId, loginPw, request.newPassword()); + return ApiResponse.success(null); + } + + @PostMapping("/me/point") + @Override + public ApiResponse chargePoint( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @RequestBody MemberV1Dto.ChargePointRequest request + ) { + memberFacade.chargePoint(loginId, loginPw, request.amount()); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 000000000..3951f23e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.vo.Money; + +import java.time.LocalDate; + +public class MemberV1Dto { + + public record RegisterRequest( + String loginId, + String password, + String name, + LocalDate birthDate, + String email + ) { + } + + public record MyInfoResponse( + String loginId, + String maskedName, + LocalDate birthDate, + String email, + Money point + ) { + } + + public record ChargePointRequest( + long amount + ) { + + } + + public record ChangePasswordRequest( + String newPassword + ) { + } +} 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..ac12cc334 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Order V1 API", description = "주문 API 입니다.") +public interface OrderV1ApiSpec { + + @Operation(summary = "주문 생성", description = "새로운 주문을 생성합니다. 재고 차감 및 포인트 차감이 수행됩니다.") + ApiResponse createOrder( + @Parameter(description = "로그인 ID") String loginId, + @Parameter(description = "로그인 비밀번호") String loginPw, + OrderV1Dto.CreateRequest request + ); + + @Operation(summary = "주문 조회", description = "ID로 주문을 조회합니다.") + ApiResponse getById(@Parameter(description = "주문 ID") Long id); + + @Operation(summary = "내 주문 목록 조회", description = "로그인한 회원의 주문 목록을 조회합니다.") + ApiResponse getMyOrders( + @Parameter(description = "로그인 ID") String loginId, + @Parameter(description = "로그인 비밀번호") String loginPw + ); +} 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..400dfbe21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @PostMapping + @Override + public ApiResponse createOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw, + @RequestBody OrderV1Dto.CreateRequest request + ) { + List itemRequests = request.items().stream() + .map(item -> new OrderFacade.OrderItemRequest(item.productId(), item.quantity())) + .toList(); + + OrderInfo info = orderFacade.createOrder(loginId, loginPw, itemRequests); + return ApiResponse.success(toResponse(info)); + } + + @GetMapping("/{id}") + @Override + public ApiResponse getById(@PathVariable Long id) { + OrderInfo info = orderFacade.getById(id); + return ApiResponse.success(toResponse(info)); + } + + @GetMapping("/me") + @Override + public ApiResponse getMyOrders( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String loginPw + ) { + List orders = orderFacade.getByMember(loginId, loginPw); + List responses = orders.stream() + .map(this::toResponse) // 각 OrderInfo를 OrderResponse로 변환 + .toList(); + return ApiResponse.success(new OrderV1Dto.OrderListResponse(responses)); + } + + private OrderV1Dto.OrderResponse toResponse(OrderInfo info) { + List items = info.items().stream() + .map(item -> new OrderV1Dto.OrderItemResponse( + item.productId(), + item.productName(), + item.brandName(), + item.productPrice(), + item.quantity(), + item.totalAmount() + )) + .toList(); + + return new OrderV1Dto.OrderResponse( + info.id(), info.memberId(), info.totalAmount(), items + ); + } +} 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..4335644a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.order; + +import java.util.List; + +public class OrderV1Dto { + + public record CreateRequest( + List items + ) { + } + + public record OrderItemRequest( + Long productId, + int quantity + ) { + } + + public record OrderResponse( + Long id, + Long memberId, + int totalAmount, + List items + ) { + } + + public record OrderItemResponse( + Long productId, + String productName, + String brandName, + int productPrice, + int quantity, + int totalAmount + ) { + } + + public record OrderListResponse( + List orders + ) { + } +} 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..717b087fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,22 @@ +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; + +@Tag(name = "Product V1 API", description = "상품 API 입니다.") +public interface ProductV1ApiSpec { + + @Operation(summary = "상품 등록", description = "새로운 상품을 등록합니다.") + ApiResponse register(ProductV1Dto.RegisterRequest request); + + @Operation(summary = "상품 상세 조회", description = "ID로 상품을 조회합니다. 브랜드명과 좋아요 수를 포함합니다.") + ApiResponse getById(@Parameter(description = "상품 ID") Long id); + + @Operation(summary = "상품 목록 조회", description = "정렬 조건에 따라 상품 목록을 조회합니다.") + ApiResponse getProducts( + @Parameter(description = "정렬: LATEST, PRICE_ASC, LIKES_DESC") String sortType, + @Parameter(description = "브랜드 ID (선택)") Long brandId + ); +} 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..546cdbba8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,95 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + // POST /api/v1/products → 상품 등록 + @PostMapping + @Override + public ApiResponse register( + @RequestBody ProductV1Dto.RegisterRequest request + ) { + ProductInfo info = productFacade.register( + request.brandId(), request.name(), request.description(), + request.price(), request.stockQuantity(), request.imageUrl() + ); + return ApiResponse.success( + new ProductV1Dto.ProductResponse( + info.id(), info.brandId(), info.name(), info.description(), info.imageUrl(), + info.price(), info.stockQuantity() + ) + ); + } + + // GET /api/v1/products/{id} → 상품 상세 조회 + @GetMapping("/{id}") + @Override + public ApiResponse getById(@PathVariable Long id) { + ProductDetailInfo info = productFacade.getById(id); + return ApiResponse.success(toDetailResponse(info)); + } + + // GET /api/v1/products?sortType=LATEST&brandId=1 → 상품 목록 조회 + @GetMapping + @Override + public ApiResponse getProducts( + @RequestParam(defaultValue = "LATEST") String sortType, + @RequestParam(required = false) Long brandId + ) { + ProductSortType sort; + try { + sort = ProductSortType.valueOf(sortType); + } catch (IllegalArgumentException e) { + String validValues = Arrays.stream(ProductSortType.values()) + .map(Enum::name) + .collect(Collectors.joining(", ")); + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("잘못된 정렬 조건입니다. 사용 가능한 값: [%s]", validValues)); + } + + List infos; + if (brandId != null) { + // 특정 브랜드 상품만 조회 + infos = productFacade.getProductsByBrandId(brandId, sort); + } else { + // 전체 상품 조회 + infos = productFacade.getProducts(sort); + } + + List responses = infos.stream() + .map(this::toDetailResponse) // 각 Info를 DTO로 변환 + .toList(); + return ApiResponse.success(new ProductV1Dto.ProductListResponse(responses)); + } + + private ProductV1Dto.ProductDetailResponse toDetailResponse(ProductDetailInfo info) { + return new ProductV1Dto.ProductDetailResponse( + info.id(), info.name(), info.description(), info.imageUrl(), + info.price(), info.stockQuantity(), info.brandName(), info.likeCount() + ); + } +} 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..3f8157ec5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,44 @@ +package com.loopers.interfaces.api.product; + +import java.util.List; + +public class ProductV1Dto { + + // 상품 등록 요청 + public record RegisterRequest( + Long brandId, + String name, + String description, + String imageUrl, + int price, + int stockQuantity + ) {} + + // 상품 등록 응답 (기본 정보) + public record ProductResponse( + Long id, + Long brandId, + String name, + String description, + String imageUrl, + int price, + int stockQuantity + ) {} + + // 상품 상세 응답 (브랜드명 + 좋아요 수 포함) + public record ProductDetailResponse( + Long id, + String name, + String description, + String imageUrl, + int price, + int stockQuantity, + String brandName, + long likeCount + ) {} + + // 상품 목록 응답 + public record ProductListResponse( + List products + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/crypto/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/support/crypto/PasswordEncoder.java new file mode 100644 index 000000000..0327bf557 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/crypto/PasswordEncoder.java @@ -0,0 +1,30 @@ +package com.loopers.support.crypto; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class PasswordEncoder { + + public static String encode(String rawPassword) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 알고리즘을 찾을 수 없습니다.", e); + } + } + + public static boolean matches(String rawPassword, String encodedPassword) { + return encode(rawPassword).equals(encodedPassword); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeUnitTest.java new file mode 100644 index 000000000..26e4c42a5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeUnitTest.java @@ -0,0 +1,94 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +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 static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BrandFacadeUnitTest { + + @Mock + private BrandService brandService; + + @InjectMocks + private BrandFacade brandFacade; + + @DisplayName("브랜드를 등록할 때,") + @Nested + class Register { + + @DisplayName("유효한 정보이면, 브랜드가 등록되고 BrandInfo가 반환된다.") + @Test + void registerSuccess() { + // given + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드", "https://example.com/nike.png"); + ReflectionTestUtils.setField(brand, "id", 1L); + when(brandService.register("나이키", "스포츠 브랜드", "https://example.com/nike.png")) + .thenReturn(brand); + + // when + BrandInfo result = brandFacade.register("나이키", "스포츠 브랜드", "https://example.com/nike.png"); + + // then + assertAll( + () -> assertThat(result.id()).isEqualTo(1L), + () -> assertThat(result.name()).isEqualTo("나이키"), + () -> assertThat(result.description()).isEqualTo("스포츠 브랜드"), + () -> assertThat(result.imageUrl()).isEqualTo("https://example.com/nike.png") + ); + } + } + + @DisplayName("브랜드를 조회할 때,") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드이면, BrandInfo가 반환된다.") + @Test + void getByIdSuccess() { + // given + BrandModel brand = new BrandModel("아디다스", "스포츠 브랜드", "https://example.com/adidas.png"); + ReflectionTestUtils.setField(brand, "id", 2L); + when(brandService.getById(2L)).thenReturn(brand); + + // when + BrandInfo result = brandFacade.getById(2L); + + // then + assertAll( + () -> assertThat(result.id()).isEqualTo(2L), + () -> assertThat(result.name()).isEqualTo("아디다스") + ); + } + + @DisplayName("존재하지 않는 브랜드이면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNotFound() { + // given + when(brandService.getById(999L)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + + // when + CoreException result = assertThrows(CoreException.class, () -> + brandFacade.getById(999L) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeUnitTest.java new file mode 100644 index 000000000..c4b35f560 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeUnitTest.java @@ -0,0 +1,172 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LikeFacadeUnitTest { + + @Mock + private ProductLikeService productLikeService; + + @Mock + private MemberService memberService; + + @Mock + private ProductService productService; + + @InjectMocks + private LikeFacade likeFacade; + + private MemberModel createMember() { + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com"); + ReflectionTestUtils.setField(member, "id", 1L); + return member; + } + + private ProductModel createProduct() { + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, null); + ReflectionTestUtils.setField(product, "id", 10L); + return product; + } + + @DisplayName("좋아요를 추가할 때,") + @Nested + class AddLike { + + @DisplayName("정상 흐름이면, 회원 인증 + 상품 검증 후 좋아요가 등록된다.") + @Test + void addLikeSuccess() { + // given + MemberModel member = createMember(); + ProductModel product = createProduct(); + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + when(productService.getById(10L)).thenReturn(product); + + // when + likeFacade.addLike("testuser", "password1!@", 10L); + + // then + verify(productLikeService).addLike(1L, 10L); + } + + @DisplayName("이미 좋아요한 상품이면, CONFLICT 예외가 발생한다.") + @Test + void failWithAlreadyLiked() { + // given + MemberModel member = createMember(); + ProductModel product = createProduct(); + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + when(productService.getById(10L)).thenReturn(product); + doThrow(new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다.")) + .when(productLikeService).addLike(1L, 10L); + + // when + CoreException result = assertThrows(CoreException.class, () -> + likeFacade.addLike("testuser", "password1!@", 10L) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithProductNotFound() { + // given + MemberModel member = createMember(); + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + when(productService.getById(999L)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + + // when + CoreException result = assertThrows(CoreException.class, () -> + likeFacade.addLike("testuser", "password1!@", 999L) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("좋아요를 취소할 때,") + @Nested + class RemoveLike { + + @DisplayName("정상 흐름이면, 좋아요가 제거된다.") + @Test + void removeLikeSuccess() { + // given + MemberModel member = createMember(); + ProductModel product = createProduct(); + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + when(productService.getById(10L)).thenReturn(product); + + // when + likeFacade.removeLike("testuser", "password1!@", 10L); + + // then + verify(productLikeService).removeLike(1L, 10L); + } + + @DisplayName("좋아요하지 않은 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNotLiked() { + // given + MemberModel member = createMember(); + ProductModel product = createProduct(); + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + when(productService.getById(10L)).thenReturn(product); + doThrow(new CoreException(ErrorType.NOT_FOUND, "좋아요하지 않은 상품입니다.")) + .when(productLikeService).removeLike(1L, 10L); + + // when + CoreException result = assertThrows(CoreException.class, () -> + likeFacade.removeLike("testuser", "password1!@", 10L) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("좋아요 수를 조회할 때,") + @Nested + class CountByProductId { + + @DisplayName("상품의 좋아요 수가 반환된다.") + @Test + void countSuccess() { + // given + when(productLikeService.countByProductId(10L)).thenReturn(5L); + + // when + long result = likeFacade.countByProductId(10L); + + // then + assertThat(result).isEqualTo(5L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeUnitTest.java new file mode 100644 index 000000000..0ed8b0a3c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/member/MemberFacadeUnitTest.java @@ -0,0 +1,183 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.point.PointModel; +import com.loopers.domain.point.PointService; +import com.loopers.domain.vo.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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MemberFacadeUnitTest { + + @Mock + private MemberService memberService; + + @Mock + private PointService pointService; + + @InjectMocks + private MemberFacade memberFacade; + + @DisplayName("회원가입할 때,") + @Nested + class Register { + + @DisplayName("정상 흐름이면, 회원 등록 후 포인트가 생성된다.") + @Test + void registerSuccess() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com"); + ReflectionTestUtils.setField(member, "id", 1L); + + when(memberService.register("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com")) + .thenReturn(member); + + // when + memberFacade.register("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com"); + + // then + verify(pointService).createPoint(1L); + } + + @DisplayName("중복 loginId이면, CONFLICT 예외가 발생한다.") + @Test + void failWithDuplicateLoginId() { + // given + when(memberService.register("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com")) + .thenThrow(new CoreException(ErrorType.CONFLICT, "이미 존재하는 loginId입니다.")); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberFacade.register("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("내 정보를 조회할 때,") + @Nested + class GetMyInfo { + + @DisplayName("정상 흐름이면, 회원 정보와 포인트가 조합된 MemberInfo가 반환된다.") + @Test + void getMyInfoSuccess() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com"); + ReflectionTestUtils.setField(member, "id", 1L); + + PointModel point = new PointModel(1L); + point.charge(Money.of(10000)); + + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + when(pointService.getByMemberId(1L)).thenReturn(point); + + // when + MemberInfo result = memberFacade.getMyInfo("testuser", "password1!@"); + + // then + assertAll( + () -> assertThat(result.loginId()).isEqualTo("testuser"), + () -> assertThat(result.maskedName()).isEqualTo("홍길*"), + () -> assertThat(result.birthDate()).isEqualTo(LocalDate.of(2000, 6, 5)), + () -> assertThat(result.email()).isEqualTo("test@example.com"), + () -> assertThat(result.point()).isEqualTo(Money.of(10000)) + ); + } + + @DisplayName("비밀번호가 틀리면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithWrongPassword() { + // given + when(memberService.getMyInfo("testuser", "wrong1234!@")) + .thenThrow(new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다.")); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberFacade.getMyInfo("testuser", "wrong1234!@") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("포인트를 충전할 때,") + @Nested + class ChargePoint { + + @DisplayName("정상 흐름이면, 회원 인증 후 포인트가 충전된다.") + @Test + void chargePointSuccess() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com"); + ReflectionTestUtils.setField(member, "id", 1L); + + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + + // when + memberFacade.chargePoint("testuser", "password1!@", 5000); + + // then + verify(pointService).charge(1L, Money.of(5000)); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + @DisplayName("정상 흐름이면, 비밀번호가 변경된다.") + @Test + void changePasswordSuccess() { + // when + memberFacade.changePassword("testuser", "password1!@", "newpass1!@#"); + + // then + verify(memberService).changePassword("testuser", "password1!@", "newpass1!@#"); + } + + @DisplayName("현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithSamePassword() { + // given + doThrow(new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호와 동일한 비밀번호는 사용할 수 없습니다.")) + .when(memberService).changePassword("testuser", "password1!@", "password1!@"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberFacade.changePassword("testuser", "password1!@", "password1!@") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeUnitTest.java new file mode 100644 index 000000000..ddcc096b5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeUnitTest.java @@ -0,0 +1,269 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.member.MemberModel; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.vo.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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderFacadeUnitTest { + + @Mock + private OrderService orderService; + + @Mock + private MemberService memberService; + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Mock + private PointService pointService; + + @InjectMocks + private OrderFacade orderFacade; + + @DisplayName("주문을 생성할 때,") + @Nested + class CreateOrder { + + @DisplayName("정상 흐름이면, 재고 차감 + 포인트 차감 + 주문 생성이 수행된다.") + @Test + void createOrderSuccess() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com"); + ReflectionTestUtils.setField(member, "id", 1L); + + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, null); + ReflectionTestUtils.setField(product, "id", 10L); + + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드", "https://example.com/nike.png"); + OrderItemModel savedItem = new OrderItemModel(10L, "에어맥스", "나이키", 129000, 2); + OrderModel order = new OrderModel(1L, List.of(savedItem)); + ReflectionTestUtils.setField(order, "id", 100L); + + // when + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + when(productService.getById(10L)).thenReturn(product); + when(brandService.getById(1L)).thenReturn(brand); + + when(orderService.createOrder(eq(1L), any())).thenReturn(order); + when(orderService.getOrderItems(100L)).thenReturn(List.of(savedItem)); + + List requests = List.of( + new OrderFacade.OrderItemRequest(10L, 2) + ); + + OrderInfo result = orderFacade.createOrder("testuser", "password1!@", requests); + + // then + assertAll( + () -> assertThat(result.totalAmount()).isEqualTo(129000 * 2), + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(result.items().get(0).brandName()).isEqualTo("나이키") + ); + + verify(productService).decreaseStock(10L, 2); + verify(pointService).use(eq(1L), eq(Money.of(129000 * 2))); + } + + @DisplayName("포인트가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithInsufficientPoint() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com"); + ReflectionTestUtils.setField(member, "id", 1L); + + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, null); + ReflectionTestUtils.setField(product, "id", 10L); + + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드", "https://example.com/nike.png"); + OrderItemModel savedItem = new OrderItemModel(10L, "에어맥스", "나이키", 129000, 2); + OrderModel order = new OrderModel(1L, List.of(savedItem)); + ReflectionTestUtils.setField(order, "id", 100L); + + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + when(productService.getById(10L)).thenReturn(product); + when(brandService.getById(1L)).thenReturn(brand); + when(orderService.createOrder(eq(1L), any())).thenReturn(order); + doThrow(new CoreException(ErrorType.BAD_REQUEST, "포인트가 부족합니다.")) + .when(pointService).use(eq(1L), eq(Money.of(129000 * 2))); + + List requests = List.of( + new OrderFacade.OrderItemRequest(10L, 2) + ); + + // when + CoreException result = assertThrows(CoreException.class, () -> + orderFacade.createOrder("testuser", "password1!@", requests) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithInsufficientStock() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com"); + ReflectionTestUtils.setField(member, "id", 1L); + + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 2, null); + ReflectionTestUtils.setField(product, "id", 10L); + + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + when(productService.getById(10L)).thenReturn(product); + doThrow(new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.")) + .when(productService).decreaseStock(10L, 10); + + List requests = List.of( + new OrderFacade.OrderItemRequest(10L, 10) + ); + + + // when + CoreException result = assertThrows(CoreException.class, () -> + orderFacade.createOrder("testuser", "password1!@", requests) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문을 단건 조회할 때,") + @Nested + class GetById { + + @DisplayName("존재하는 주문이면, OrderInfo가 반환된다.") + @Test + void getByIdSuccess() { + // given + OrderItemModel item = new OrderItemModel(10L, "에어맥스", "나이키", 129000, 2); + OrderModel order = new OrderModel(1L, List.of(item)); + ReflectionTestUtils.setField(order, "id", 100L); + + when(orderService.getById(100L)).thenReturn(order); + when(orderService.getOrderItems(100L)).thenReturn(List.of(item)); + + // when + OrderInfo result = orderFacade.getById(100L); + + // then + assertAll( + () -> assertThat(result.id()).isEqualTo(100L), + () -> assertThat(result.memberId()).isEqualTo(1L), + () -> assertThat(result.totalAmount()).isEqualTo(129000 * 2), + () -> assertThat(result.items()).hasSize(1) + ); + } + + @DisplayName("존재하지 않는 주문이면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNotFound() { + // given + when(orderService.getById(999L)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 주문입니다.")); + + // when + CoreException result = assertThrows(CoreException.class, () -> + orderFacade.getById(999L) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("내 주문 목록을 조회할 때,") + @Nested + class GetByMember { + + @DisplayName("정상 흐름이면, 해당 회원의 주문 목록이 반환된다.") + @Test + void getByMemberSuccess() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com"); + ReflectionTestUtils.setField(member, "id", 1L); + + OrderItemModel item1 = new OrderItemModel(10L, "에어맥스", "나이키", 129000, 1); + OrderModel order1 = new OrderModel(1L, List.of(item1)); + ReflectionTestUtils.setField(order1, "id", 100L); + + OrderItemModel item2 = new OrderItemModel(20L, "슈퍼스타", "아디다스", 99000, 2); + OrderModel order2 = new OrderModel(1L, List.of(item2)); + ReflectionTestUtils.setField(order2, "id", 101L); + + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + when(orderService.getByMemberId(1L)).thenReturn(List.of(order1, order2)); + when(orderService.getOrderItems(100L)).thenReturn(List.of(item1)); + when(orderService.getOrderItems(101L)).thenReturn(List.of(item2)); + + // when + List result = orderFacade.getByMember("testuser", "password1!@"); + + // then + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(0).id()).isEqualTo(100L), + () -> assertThat(result.get(1).id()).isEqualTo(101L) + ); + } + + @DisplayName("주문이 없으면, 빈 목록이 반환된다.") + @Test + void emptyList() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", + LocalDate.of(2000, 6, 5), "test@example.com"); + ReflectionTestUtils.setField(member, "id", 1L); + + when(memberService.getMyInfo("testuser", "password1!@")).thenReturn(member); + when(orderService.getByMemberId(1L)).thenReturn(List.of()); + + // when + List result = orderFacade.getByMember("testuser", "password1!@"); + + // then + assertThat(result).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeUnitTest.java new file mode 100644 index 000000000..e5929316a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeUnitTest.java @@ -0,0 +1,221 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductSortType; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ProductFacadeUnitTest { + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Mock + private ProductLikeService productLikeService; + + @InjectMocks + private ProductFacade productFacade; + + @DisplayName("상품 상세를 조회할 때,") + @Nested + class GetById { + + @DisplayName("존재하는 상품이면, Product + Brand + likeCount가 조합된다.") + @Test + void getByIdSuccess() { + // given + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, "https://example.com/nike.png"); + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드", "https://example.com/nike.png"); + when(productService.getById(1L)).thenReturn(product); + when(brandService.getById(1L)).thenReturn(brand); + when(productLikeService.countByProductId(1L)).thenReturn(5L); + + // when + ProductDetailInfo result = productFacade.getById(1L); + + // then + assertAll( + () -> assertThat(result.name()).isEqualTo("에어맥스"), + () -> assertThat(result.brandName()).isEqualTo("나이키"), + () -> assertThat(result.likeCount()).isEqualTo(5L) + ); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNotFoundProduct() { + // given + when(productService.getById(999L)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")); + + // when + CoreException result = assertThrows(CoreException.class, () -> + productFacade.getById(999L) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 목록을 정렬 조회할 때,") + @Nested + class GetProductsWithSort { + + @DisplayName("PRICE_ASC로 정렬하면, 가격 오름차순으로 반환된다.") + @Test + void sortByPriceAsc() { + // given + ProductModel expensive = new ProductModel(1L, "에어맥스", "러닝화", 200000, 100, null); + ReflectionTestUtils.setField(expensive, "id", 10L); + ProductModel cheap = new ProductModel(1L, "에어포스", "캐주얼화", 100000, 50, null); + ReflectionTestUtils.setField(cheap, "id", 11L); + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드", "https://example.com/nike.png"); + ReflectionTestUtils.setField(brand, "id", 1L); + + when(productService.getAll()).thenReturn(List.of(expensive, cheap)); + when(brandService.getById(1L)).thenReturn(brand); + when(productLikeService.countByProductId(10L)).thenReturn(0L); + when(productLikeService.countByProductId(11L)).thenReturn(0L); + + // when + List result = productFacade.getProducts(ProductSortType.PRICE_ASC); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).price()) + .isLessThanOrEqualTo(result.get(1).price()); + } + + @DisplayName("LIKES_DESC로 정렬하면, 좋아요 수 내림차순으로 반환된다.") + @Test + void sortByLikesDesc() { + // given + ProductModel p1 = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, null); + ReflectionTestUtils.setField(p1, "id", 10L); + ProductModel p2 = new ProductModel(1L, "에어포스", "캐주얼화", 109000, 50, null); + ReflectionTestUtils.setField(p2, "id", 11L); + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드", "https://example.com/nike.png"); + ReflectionTestUtils.setField(brand, "id", 1L); + + when(productService.getAll()).thenReturn(List.of(p1, p2)); + when(brandService.getById(1L)).thenReturn(brand); + when(productLikeService.countByProductId(10L)).thenReturn(2L); + when(productLikeService.countByProductId(11L)).thenReturn(10L); + + // when + List result = productFacade.getProducts(ProductSortType.LIKES_DESC); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).likeCount()).isGreaterThanOrEqualTo(result.get(1).likeCount()); + } + } + + @DisplayName("상품을 등록할 때,") + @Nested + class Register { + + @DisplayName("존재하는 브랜드이면, 상품이 등록되고 ProductInfo가 반환된다.") + @Test + void registerSuccess() { + // given + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드", "https://example.com/nike.png"); + ReflectionTestUtils.setField(brand, "id", 1L); + + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, "https://example.com/airmax.png"); + ReflectionTestUtils.setField(product, "id", 10L); + + when(brandService.getById(1L)).thenReturn(brand); + when(productService.register(1L, "에어맥스", "러닝화", 129000, 100, "https://example.com/airmax.png")) + .thenReturn(product); + + // when + ProductInfo result = productFacade.register(1L, "에어맥스", "러닝화", 129000, 100, "https://example.com/airmax.png"); + + // then + assertAll( + () -> assertThat(result.id()).isEqualTo(10L), + () -> assertThat(result.brandId()).isEqualTo(1L), + () -> assertThat(result.name()).isEqualTo("에어맥스"), + () -> assertThat(result.price()).isEqualTo(129000), + () -> assertThat(result.stockQuantity()).isEqualTo(100) + ); + verify(brandService).getById(1L); + } + + @DisplayName("존재하지 않는 브랜드이면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithBrandNotFound() { + // given + when(brandService.getById(999L)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")); + + // when + CoreException result = assertThrows(CoreException.class, () -> + productFacade.register(999L, "에어맥스", "러닝화", 129000, 100, null) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드별 상품 목록을 조회할 때,") + @Nested + class GetProductsByBrandId { + + @DisplayName("해당 브랜드의 상품만 반환된다.") + @Test + void getProductsByBrandIdSuccess() { + // given + ProductModel p1 = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, null); + ReflectionTestUtils.setField(p1, "id", 10L); + ProductModel p2 = new ProductModel(1L, "에어포스", "캐주얼화", 109000, 50, null); + ReflectionTestUtils.setField(p2, "id", 11L); + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드", "https://example.com/nike.png"); + ReflectionTestUtils.setField(brand, "id", 1L); + + when(productService.getByBrandId(1L)).thenReturn(List.of(p1, p2)); + when(brandService.getById(1L)).thenReturn(brand); + when(productLikeService.countByProductId(10L)).thenReturn(0L); + when(productLikeService.countByProductId(11L)).thenReturn(0L); + + // when + List result = productFacade.getProductsByBrandId(1L, ProductSortType.LATEST); + + // then + assertAll( + () -> assertThat(result).hasSize(2), + () -> assertThat(result.get(0).brandName()).isEqualTo("나이키"), + () -> assertThat(result.get(1).brandName()).isEqualTo("나이키") + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java new file mode 100644 index 000000000..a354fb220 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class BrandModelTest { + + @DisplayName("브랜드를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 이름이면, 정상적으로 생성된다.") + @Test + void createBrand() { + // given + String name = "나이키"; + String description = "스포츠 브랜드"; + String imageUrl = "https://example.com/nike.png"; + + // when + BrandModel brand = new BrandModel(name, description, imageUrl); + + // then + assertAll( + () -> assertThat(brand.getName()).isEqualTo(name), + () -> assertThat(brand.getDescription()).isEqualTo(description), + () -> assertThat(brand.getImageUrl()).isEqualTo(imageUrl) + ); + } + + @DisplayName("이름이 null이면, BAD_REQUEST를 반환한다.") + @Test + void failWithNullName() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new BrandModel(null, "설명", "url")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈값이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithEmptyName() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> new BrandModel(" ", "설명", "url")); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceUnitTest.java new file mode 100644 index 000000000..0b31374a1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceUnitTest.java @@ -0,0 +1,102 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class BrandServiceUnitTest { + + @Mock + private BrandRepository brandRepository; + + @InjectMocks + private BrandService brandService; + + @DisplayName("브랜드를 등록할 때,") + @Nested + class Register { + + @DisplayName("유효한 이름이면, 정상적으로 등록된다") + @Test + void registerSuccess() { + // given + String name = "나이키"; + String description = "스포츠 브랜드"; + String imageUrl = "https://example.com/nike.png"; + when(brandRepository.save(any(BrandModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + BrandModel result = brandService.register(name, description, imageUrl); + + // then + assertAll( + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getDescription()).isEqualTo(description), + () -> assertThat(result.getImageUrl()).isEqualTo(imageUrl) + ); + verify(brandRepository, times(1)).save(any(BrandModel.class)); + } + + @DisplayName("이름이 null이면, save가 호출되지 않고 예외가 발생한다.") + @Test + void failWithNullName() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + brandService.register(null, "설명", "url") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(brandRepository, never()).save(any(BrandModel.class)); + } + } + + @DisplayName("브랜드를 조회할 때,") + @Nested + class GetById { + + @DisplayName("존재하는 ID면, 브랜드가 반환된다.") + @Test + void getByIdSuccess() { + // given + Long id = 1L; + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드", "https://example.com/nike.png"); + when(brandRepository.findById(id)).thenReturn(Optional.of(brand)); + + // when + BrandModel result = brandService.getById(id); + + // then + assertThat(result.getName()).isEqualTo("나이키"); + } + + @DisplayName("존재하지 않는 ID면, NOT_FOUND가 반환된다.") + @Test + void failWithNotFoundId() { + // given + when(brandRepository.findById(999L)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, () -> brandService.getById(999L)); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -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 ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe1..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeModelTest.java new file mode 100644 index 000000000..93fac571f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeModelTest.java @@ -0,0 +1,60 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductLikeModelTest { + + @DisplayName("좋아요를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 정보면, 정상적으로 생성된다.") + @Test + void createLike() { + // given + Long memberId = 1L; + Long productId = 1L; + + // when + ProductLikeModel like = new ProductLikeModel(memberId, productId); + + // then + assertAll( + () -> assertThat(like.getMemberId()).isEqualTo(memberId), + () -> assertThat(like.getProductId()).isEqualTo(productId) + ); + } + + @DisplayName("회원 ID가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullMemberId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new ProductLikeModel(null, 1L) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품 ID가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullProductId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new ProductLikeModel(1L, null) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceUnitTest.java new file mode 100644 index 000000000..7c3e75b76 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceUnitTest.java @@ -0,0 +1,128 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class ProductLikeServiceUnitTest { + + @Mock + private ProductLikeRepository productLikeRepository; + + @InjectMocks + private ProductLikeService productLikeService; + + @DisplayName("좋아요를 등록할 때,") + @Nested + class AddLike { + + @DisplayName("기존 좋아요가 없으면, 새로 생성된다.") + @Test + void createNewLike() { + // given + Long memberId = 1L; + Long productId = 1L; + when(productLikeRepository.findByMemberIdAndProductId(memberId, productId)) + .thenReturn(Optional.empty()); + when(productLikeRepository.save(any(ProductLikeModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + productLikeService.addLike(memberId, productId); + + // then + verify(productLikeRepository, times(1)).save(any(ProductLikeModel.class)); + } + + @DisplayName("기존 좋아요가 있으면, CONFLICT 예외가 발생한다.") + @Test + void failWithExistingLike() { + // given + Long memberId = 1L; + Long productId = 1L; + ProductLikeModel like = new ProductLikeModel(memberId, productId); + when(productLikeRepository.findByMemberIdAndProductId(memberId, productId)) + .thenReturn(Optional.of(like)); + + // when + CoreException result = assertThrows(CoreException.class, () -> + productLikeService.addLike(memberId, productId) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("좋아요를 취소할 때,") + @Nested + class RemoveLike { + + @DisplayName("기존 좋아요가 있으면, 삭제된다.") + @Test + void deleteLike() { + // given + Long memberId = 1L; + Long productId = 1L; + ProductLikeModel like = new ProductLikeModel(memberId, productId); + when(productLikeRepository.findByMemberIdAndProductId(memberId, productId)) + .thenReturn(Optional.of(like)); + + // when + productLikeService.removeLike(memberId, productId); + + // then + verify(productLikeRepository, times(1)).delete(like); + } + + @DisplayName("기존 좋아요가 없으면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNoLike() { + // given + Long memberId = 1L; + Long productId = 1L; + when(productLikeRepository.findByMemberIdAndProductId(memberId, productId)) + .thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, () -> + productLikeService.removeLike(memberId, productId) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품의 좋아요 수를 조회할 때,") + @Nested + class CountByProductId { + + @DisplayName("좋아요 수가 반환된다.") + @Test + void countSuccess() { + // given + when(productLikeRepository.countByProductId(1L)).thenReturn(5L); + + // when + long count = productLikeService.countByProductId(1L); + + // then + assertThat(count).isEqualTo(5L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java new file mode 100644 index 000000000..1ce037ee8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberModelTest.java @@ -0,0 +1,310 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MemberModelTest { + + @DisplayName("회원을 생성할 때,") + @Nested + class Create { + + @DisplayName("모든 정보가 유효하면, 정상적으로 생성된다.") + @Test + void createMember() { + // given + String loginId = "testuser"; + String password = "password1!@"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(2000, 6, 5); + String email = "test@example.com"; + + // when + MemberModel member = new MemberModel(loginId, password, name, birthDate, email); + + // then + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo(loginId), + () -> assertThat(member.getPassword()).isEqualTo(password), + () -> assertThat(member.getName()).isEqualTo(name), + () -> assertThat(member.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(member.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("loginId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel(null, "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId가 빈값이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithEmptyLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel(" ", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId에 특수문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithSpecialCharLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel("test@user!", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("loginId에 한글이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithKoreanLoginId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel("테스트유저", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password에 허용되지 않는 문자(한글)가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithKoreanPassword() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel("testuser", "비밀번호한글입력!", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password에 공백이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithSpaceInPassword() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel("testuser", "pass word1!", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password가 8자 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithShortPassword() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel("testuser", "pass1!", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password가 16자 초과이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithLongPassword() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel("testuser", "a".repeat(17), "홍길동", LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("password에 생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithBirthDateInPassword() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel("testuser", "pass20000605!", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullName() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel("testuser", "password1!@", null, LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("name이 빈값이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithEmptyName() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel("testuser", "password1!@", " ", LocalDate.of(2000, 6, 5), "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("email 형식이 아니면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithInvalidEmail() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel("testuser", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "invalid-email") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("birthDate가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullBirthDate() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new MemberModel("testuser", "password1!@", "홍길동", null, "test@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름을 마스킹할 때,") + @Nested + class MaskedName { + + @DisplayName("마지막 글자가 *로 마스킹된다.") + @Test + void maskedLastCharacter() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + // when + String result = member.getMaskedName(); + + // then + assertThat(result).isEqualTo("홍길*"); + } + + @DisplayName("한 글자 이름이면, *로 마스킹된다.") + @Test + void maskedSingleCharacterName() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍", LocalDate.of(2000, 6, 5), "test@example.com"); + + // when + String result = member.getMaskedName(); + + // then + assertThat(result).isEqualTo("*"); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + @DisplayName("유효한 새 비밀번호로 변경하면, 비밀번호가 변경된다.") + @Test + void changePasswordSuccess() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + String newPassword = "newpass1!@"; + + // when + member.changePassword(newPassword); + + // then + assertThat(member.getPassword()).isEqualTo(newPassword); + } + + @DisplayName("새 비밀번호에 허용되지 않는 문자(한글)가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithKoreanNewPassword() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword("새비밀번호입력!@") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 8자 미만이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithShortNewPassword() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword("short!") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 16자 초과이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithLongNewPassword() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword("a".repeat(17)) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("새 비밀번호에 생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithBirthDateInNewPassword() { + // given + MemberModel member = new MemberModel("testuser", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + member.changePassword("pass20000605!") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java new file mode 100644 index 000000000..961f57d10 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceIntegrationTest.java @@ -0,0 +1,130 @@ +package com.loopers.domain.member; + +import com.loopers.support.crypto.PasswordEncoder; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import 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 MemberServiceIntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원가입 후 조회할 때,") + @Nested + class RegisterAndGetMyInfo { + + @DisplayName("회원가입 후 해당 계정으로 조회하면, 회원 정보가 정상 반환된다.") + @Test + void registerAndGetMyInfoSuccess() { + // given + String loginId = "testuser"; + String password = "password1!@"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(2000, 6, 5); + String email = "test@example.com"; + + memberService.register(loginId, password, name, birthDate, email); + + // when + MemberModel result = memberService.getMyInfo(loginId, password); + + // then + assertAll( + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email), + () -> assertThat(result.getPassword()).isEqualTo(PasswordEncoder.encode(password)) + ); + } + } + + @DisplayName("중복 loginId로 가입할 때,") + @Nested + class DuplicateLoginId { + + @DisplayName("이미 존재하는 loginId로 가입하면, CONFLICT 예외가 발생한다.") + @Test + void failWithDuplicateLoginId() { + // given + String loginId = "testuser"; + memberService.register(loginId, "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberService.register(loginId, "other1234!@", "김철수", LocalDate.of(1995, 3, 10), "other@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("존재하지 않는 회원을 조회할 때,") + @Nested + class NotFoundMember { + + @DisplayName("존재하지 않는 loginId로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNotFoundMember() { + // given + String loginId = "nonexistent"; + String password = "password1!@"; + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberService.getMyInfo(loginId, password) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("비밀번호를 변경할 때,") + @Nested + class ChangePassword { + + @DisplayName("비밀번호 변경 후 새 비밀번호로 조회하면, 정상 반환된다.") + @Test + void changePasswordAndGetMyInfoSuccess() { + // given + String loginId = "testuser"; + String currentPassword = "password1!@"; + String newPassword = "newpass1!@#"; + memberService.register(loginId, currentPassword, "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + // when + memberService.changePassword(loginId, currentPassword, newPassword); + + // then + MemberModel result = memberService.getMyInfo(loginId, newPassword); + assertAll( + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getPassword()).isEqualTo(PasswordEncoder.encode(newPassword)) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceUnitTest.java new file mode 100644 index 000000000..5c026de28 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceUnitTest.java @@ -0,0 +1,319 @@ +package com.loopers.domain.member; + +import com.loopers.support.crypto.PasswordEncoder; +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MemberServiceUnitTest { + + @Mock + private MemberRepository memberRepository; + + @InjectMocks + private MemberService memberService; + + @DisplayName("[Dummy] 의존성이 사용되지 않는 경우,") + @Nested + class DummyTest { + + @DisplayName("현재 비밀번호와 동일한 비밀번호로 변경하면, Repository에 접근하지 않고 예외가 발생한다.") + @Test + void failWithSamePasswordWithoutRepositoryAccess() { + // given + MemberRepository dummyRepository = mock(MemberRepository.class); + MemberService service = new MemberService(dummyRepository); + + // when + CoreException result = assertThrows(CoreException.class, () -> + service.changePassword("testuser", "password1!@", "password1!@") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verifyNoInteractions(dummyRepository); + } + } + + @DisplayName("[Stub] 고정된 응답으로 흐름을 제어할 때,") + @Nested + class StubTest { + + @DisplayName("findByLoginId가 빈 값을 반환하도록 stub하면, 회원가입이 정상 처리된다.") + @Test + void registerSuccessWithStub() { + // given + String loginId = "testuser"; + String password = "password1!@"; + String name = "홍길동"; + LocalDate birthDate = LocalDate.of(2000, 6, 5); + String email = "test@example.com"; + + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.empty()); + when(memberRepository.save(any(MemberModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + MemberModel result = memberService.register(loginId, password, name, birthDate, email); + + // then + assertAll( + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getPassword()).isEqualTo(PasswordEncoder.encode(password)), + () -> assertThat(result.getPassword()).isNotEqualTo(password) + ); + } + + @DisplayName("findByLoginId가 회원을 반환하도록 stub하면, 내 정보가 조회된다.") + @Test + void getMyInfoSuccessWithStub() { + // given + String loginId = "testuser"; + String password = "password1!@"; + MemberModel member = new MemberModel(loginId, password, "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + member.applyEncodedPassword(PasswordEncoder.encode(password)); + + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.of(member)); + + // when + MemberModel result = memberService.getMyInfo(loginId, password); + + // then + assertAll( + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo("홍길동") + ); + } + + @DisplayName("findByLoginId가 빈 값을 반환하도록 stub하면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNotFoundLoginId() { + // given + when(memberRepository.findByLoginId("nonexistent")).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberService.getMyInfo("nonexistent", "password1!@") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("findByLoginId가 다른 비밀번호를 가진 회원을 반환하도록 stub하면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithWrongPassword() { + // given + String loginId = "testuser"; + MemberModel member = new MemberModel(loginId, "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + member.applyEncodedPassword(PasswordEncoder.encode("password1!@")); + + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.of(member)); + + // when + CoreException result = assertThrows(CoreException.class, () -> + memberService.getMyInfo(loginId, "wrongpass1!@") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("[Mock] 메서드 호출 여부를 검증할 때,") + @Nested + class MockTest { + + @DisplayName("회원가입 성공 시, save가 1회 호출된다.") + @Test + void verifySaveCalledOnRegister() { + // given + String loginId = "testuser"; + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.empty()); + when(memberRepository.save(any(MemberModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // when + memberService.register(loginId, "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + // then + verify(memberRepository, times(1)).findByLoginId(loginId); + verify(memberRepository, times(1)).save(any(MemberModel.class)); + } + + @DisplayName("중복 loginId로 가입 시, save가 호출되지 않는다.") + @Test + void verifySaveNotCalledOnDuplicateLoginId() { + // given + String loginId = "testuser"; + MemberModel existingMember = new MemberModel(loginId, "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.of(existingMember)); + + // when + assertThrows(CoreException.class, () -> + memberService.register(loginId, "other1234!@", "김철수", LocalDate.of(1995, 3, 10), "other@example.com") + ); + + // then + verify(memberRepository, times(1)).findByLoginId(loginId); + verify(memberRepository, never()).save(any(MemberModel.class)); + } + + @DisplayName("비밀번호 변경 성공 시, findByLoginId가 1회 호출된다.") + @Test + void verifyFindByLoginIdCalledOnChangePassword() { + // given + String loginId = "testuser"; + String currentPassword = "password1!@"; + MemberModel member = new MemberModel(loginId, currentPassword, "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + member.applyEncodedPassword(PasswordEncoder.encode(currentPassword)); + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.of(member)); + + // when + memberService.changePassword(loginId, currentPassword, "newpass1!@#"); + + // then + verify(memberRepository, times(1)).findByLoginId(loginId); + } + } + + @DisplayName("[Spy] 실제 객체의 동작을 감시할 때,") + @Nested + class SpyTest { + + @DisplayName("changePassword 호출 시, 내부적으로 getMyInfo가 호출된다.") + @Test + void verifyGetMyInfoCalledInChangePassword() { + // given + String loginId = "testuser"; + String currentPassword = "password1!@"; + String newPassword = "newpass1!@#"; + MemberModel member = new MemberModel(loginId, currentPassword, "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + member.applyEncodedPassword(PasswordEncoder.encode(currentPassword)); + + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.of(member)); + MemberService spyService = spy(new MemberService(memberRepository)); + + // when + spyService.changePassword(loginId, currentPassword, newPassword); + + // then + verify(spyService).getMyInfo(loginId, currentPassword); + assertThat(member.getPassword()).isEqualTo(PasswordEncoder.encode(newPassword)); + } + + @DisplayName("register 호출 시, 실제 암호화 로직이 동작하며 save가 호출된다.") + @Test + void spyServiceCallsRealRegisterLogic() { + // given + String loginId = "testuser"; + String password = "password1!@"; + + when(memberRepository.findByLoginId(loginId)).thenReturn(Optional.empty()); + when(memberRepository.save(any(MemberModel.class))).thenAnswer(invocation -> invocation.getArgument(0)); + MemberService spyService = spy(new MemberService(memberRepository)); + + // when + MemberModel result = spyService.register(loginId, password, "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + // then + verify(spyService).register(loginId, password, "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + assertThat(result.getPassword()).isEqualTo(PasswordEncoder.encode(password)); + } + } + + @DisplayName("[Fake] InMemoryMemberRepository로 테스트할 때,") + @Nested + class FakeTest { + + static class InMemoryMemberRepository implements MemberRepository { + private final Map store = new HashMap<>(); + + @Override + public MemberModel save(MemberModel member) { + store.put(member.getLoginId(), member); + return member; + } + + @Override + public Optional findByLoginId(String loginId) { + return Optional.ofNullable(store.get(loginId)); + } + } + + @DisplayName("회원가입 후 내 정보 조회가 정상 동작한다.") + @Test + void registerAndGetMyInfo() { + // given + InMemoryMemberRepository fakeRepository = new InMemoryMemberRepository(); + MemberService service = new MemberService(fakeRepository); + + String loginId = "testuser"; + String password = "password1!@"; + + // when + service.register(loginId, password, "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + MemberModel result = service.getMyInfo(loginId, password); + + // then + assertAll( + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo("홍길동"), + () -> assertThat(result.getPassword()).isEqualTo(PasswordEncoder.encode(password)) + ); + } + + @DisplayName("중복 loginId로 가입 시 CONFLICT 예외가 발생한다.") + @Test + void failWithDuplicateLoginId() { + // given + InMemoryMemberRepository fakeRepository = new InMemoryMemberRepository(); + MemberService service = new MemberService(fakeRepository); + service.register("testuser", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + service.register("testuser", "other1234!@", "김철수", LocalDate.of(1995, 3, 10), "other@example.com") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("비밀번호 변경 후 새 비밀번호로 조회가 가능하다.") + @Test + void changePasswordAndVerify() { + // given + InMemoryMemberRepository fakeRepository = new InMemoryMemberRepository(); + MemberService service = new MemberService(fakeRepository); + String loginId = "testuser"; + String currentPassword = "password1!@"; + String newPassword = "newpass1!@#"; + service.register(loginId, currentPassword, "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + // when + service.changePassword(loginId, currentPassword, newPassword); + + // then + MemberModel result = service.getMyInfo(loginId, newPassword); + assertThat(result.getLoginId()).isEqualTo(loginId); + } + } +} 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..78a3955d5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -0,0 +1,171 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class OrderModelTest { + + @DisplayName("주문 항목을 생성할 때,") + @Nested + class CreateOrderItem { + + @DisplayName("유효한 정보면, 정상적으로 생성된다.") + @Test + void createOrderItemSuccess() { + // given & when + OrderItemModel item = new OrderItemModel(1L, "에어맥스", "나이키", 129000, 2); + + // then + assertAll( + () -> assertThat(item.getProductId()).isEqualTo(1L), + () -> assertThat(item.getProductName()).isEqualTo("에어맥스"), + () -> assertThat(item.getBrandName()).isEqualTo("나이키"), + () -> assertThat(item.getProductPrice()).isEqualTo(129000), + () -> assertThat(item.getQuantity()).isEqualTo(2), + () -> assertThat(item.getTotalAmount()).isEqualTo(258000) + ); + } + + @DisplayName("상품 ID가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullProductId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new OrderItemModel(null, "에어맥스", "나이키", 129000, 2) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품명이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullProductName() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new OrderItemModel(1L, null, "나이키", 129000, 2) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNegativePrice() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new OrderItemModel(1L, "에어맥스", "나이키", -1, 2) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithZeroQuantity() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new OrderItemModel(1L, "에어맥스", "나이키", 129000, 0) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문 항목에 주문 ID를 할당할 때,") + @Nested + class AssignOrderId { + + @DisplayName("주문 ID가 할당된다.") + @Test + void assignOrderIdSuccess() { + // given + OrderItemModel item = new OrderItemModel(1L, "에어맥스", "나이키", 129000, 2); + + // when + item.assignOrderId(100L); + + // then + assertThat(item.getOrderId()).isEqualTo(100); + } + } + + @DisplayName("주문을 생성할 때,") + @Nested + class CreateOrder { + + @DisplayName("유효한 정보면, 정상적으로 생성된다.") + @Test + void createOrderSuccess() { + // given + Long memberId = 1L; + List items = List.of( + new OrderItemModel(1L, "에어맥스", "나이키", 129000, 2), + new OrderItemModel(2L, "에어포스", "나이키", 109000, 1) + ); + + // when + OrderModel order = new OrderModel(memberId, items); + + // then + assertAll( + () -> assertThat(order.getMemberId()).isEqualTo(memberId), + () -> assertThat(order.getTotalAmount()).isEqualTo(367000) + ); + } + + @DisplayName("회원 ID가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullMemberId() { + // given + List items = List.of( + new OrderItemModel(1L, "에어맥스", "나이키", 129000, 2) + ); + + // when + CoreException result = assertThrows(CoreException.class, () -> + new OrderModel(null, items) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 항목이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullItems() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new OrderModel(1L, null) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 항목이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithEmptyItems() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new OrderModel(1L, Collections.emptyList()) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceUnitTest.java new file mode 100644 index 000000000..8bca5653c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceUnitTest.java @@ -0,0 +1,119 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class OrderServiceUnitTest { + + @Mock + private OrderRepository orderRepository; + + @InjectMocks + private OrderService orderService; + + @DisplayName("주문을 생성할 때,") + @Nested + class CreateOrder { + + @DisplayName("유효한 정보면, 주문과 주문 항목이 저장된다.") + @Test + void createOrderSuccess() { + // given + Long memberId = 1L; + List items = List.of( + new OrderItemModel(1L, "에어맥스", "나이키", 129000, 2), + new OrderItemModel(2L, "에어포스", "나이키", 109000, 1) + ); + + when(orderRepository.save(any(OrderModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(orderRepository.saveItem(any(OrderItemModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + OrderModel result = orderService.createOrder(memberId, items); + + // then + assertAll( + () -> assertThat(result.getMemberId()).isEqualTo(memberId), + () -> assertThat(result.getTotalAmount()).isEqualTo(129000 * 2 + 109000) + ); + verify(orderRepository, times(1)).save(any(OrderModel.class)); + verify(orderRepository, times(2)).saveItem(any(OrderItemModel.class)); + } + } + + @DisplayName("주문을 조회할 때,") + @Nested + class GetById { + + @DisplayName("존재하는 ID면, 주문이 반환된다.") + @Test + void getByIdSuccess() { + // given + List items = List.of(new OrderItemModel(1L, "에어맥스", "나이키", 129000, 1)); + OrderModel order = new OrderModel(1L, items); + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when + OrderModel result = orderService.getById(1L); + + // then + assertThat(result.getMemberId()).isEqualTo(1L); + } + + @DisplayName("존재하지 않는 ID면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNotFoundId() { + // given + when(orderRepository.findById(999L)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, () -> + orderService.getById(999L) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("회원의 주문 목록을 조회할 때,") + @Nested + class GetByMemberId { + + @DisplayName("해당 회원의 주문 목록이 반환된다.") + @Test + void getByMemberIdSuccess() { + // given + List items = List.of( + new OrderItemModel(1L, "에어맥스", "나이키", 129000, 1) + ); + List orders = List.of(new OrderModel(1L, items)); + when(orderRepository.findAllByMemberId(1L)).thenReturn(orders); + + // when + List result = orderService.getByMemberId(1L); + + // then + assertThat(result).hasSize(1); + } + } +} 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..b6150f825 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java @@ -0,0 +1,144 @@ +package com.loopers.domain.point; + +import com.loopers.domain.vo.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; +public class PointModelTest { + + @DisplayName("포인트를 생성할 때,") + @Nested + class Create { + + @DisplayName("유효한 회원 ID면, 잔액이 0으로 생성된다.") + @Test + void createPoint() { + // given + Long memberId = 1L; + + // when + PointModel point = new PointModel(memberId); + + // then + assertAll( + () -> assertThat(point.getMemberId()).isEqualTo(memberId), + () -> assertThat(point.getBalanceMoney()).isEqualTo(Money.of(0)) + ); + } + + @DisplayName("회원 ID가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullMemberId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new PointModel(null) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("포인트를 충전할 때,") + @Nested + class Charge { + + @DisplayName("양수 금액이면, 정상적으로 충전된다.") + @Test + void chargeSuccess() { + // given + PointModel point = new PointModel(1L); + + // when + point.charge(Money.of(10000)); + + // then + assertThat(point.getBalanceMoney()).isEqualTo(Money.of(10000)); + } + + @DisplayName("0 이하 금액이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithZeroAmount() { + // given + PointModel point = new PointModel(1L); + + // when + CoreException result = assertThrows(CoreException.class, () -> + point.charge(Money.zero()) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("포인트를 사용할 때,") + @Nested + class Use { + + @DisplayName("충분한 잔액이 있으면, 정상적으로 차감된다.") + @Test + void useSuccess() { + // given + PointModel point = new PointModel(1L); + point.charge(Money.of(10000)); + + // when + point.use(Money.of(3000)); + + // then + assertThat(point.getBalanceMoney()).isEqualTo(Money.of(7000)); + } + + @DisplayName("잔액이 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithInsufficientBalance() { + // given + PointModel point = new PointModel(1L); + point.charge(Money.of(1000)); + + // when + CoreException result = assertThrows(CoreException.class, () -> + point.use(Money.of(5000)) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("사용 금액이 0 이하이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithZeroAmount() { + // given + PointModel point = new PointModel(1L); + + // when + CoreException result = assertThrows(CoreException.class, () -> + point.use(Money.zero()) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("잔액과 동일한 금액을 사용하면, 잔액이 0이 된다.") + @Test + void useExactBalance() { + // given + PointModel point = new PointModel(1L); + point.charge(Money.of(5000)); + + // when + point.use(Money.of(5000)); + + // then + assertThat(point.getBalanceMoney()).isEqualTo(Money.of(0)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceUnitTest.java new file mode 100644 index 000000000..102fc672d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceUnitTest.java @@ -0,0 +1,188 @@ +package com.loopers.domain.point; + +import com.loopers.domain.vo.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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PointServiceUnitTest { + + @Mock + private PointRepository pointRepository; + + @InjectMocks + private PointService pointService; + + @DisplayName("포인트를 생성할 때,") + @Nested + class CreatePoint { + + @DisplayName("유효한 회원 ID면, 포인트가 생성된다.") + @Test + void createPointSuccess() { + // given + Long memberId = 1L; + when(pointRepository.save(any(PointModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + PointModel result = pointService.createPoint(memberId); + + // then + assertAll( + () -> assertThat(result.getMemberId()).isEqualTo(memberId), + () -> assertThat(result.getBalanceMoney()).isEqualTo(Money.of(0)) + ); + verify(pointRepository, times(1)).save(any(PointModel.class)); + } + } + + @DisplayName("포인트를 충전할 때,") + @Nested + class Charge { + + @DisplayName("존재하는 회원이면, 포인트가 충전된다.") + @Test + void chargeSuccess() { + // given + Long memberId = 1L; + PointModel point = new PointModel(memberId); + when(pointRepository.findByMemberId(memberId)) + .thenReturn(Optional.of(point)); + + // when + pointService.charge(memberId, Money.of(10000)); + + // then + assertThat(point.getBalanceMoney()).isEqualTo(Money.of(10000)); + } + + @DisplayName("존재하지 않는 회원이면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNotFoundMember() { + // given + when(pointRepository.findByMemberId(999L)) + .thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, () -> + pointService.charge(999L, Money.of(10000)) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + } + + @DisplayName("포인트를 사용할 때,") + @Nested + class Use { + + @DisplayName("충분한 잔액이 있으면, 정상적으로 차감된다.") + @Test + void useSuccess() { + // given + Long memberId = 1L; + PointModel point = new PointModel(memberId); + point.charge(Money.of(10000)); + when(pointRepository.findByMemberId(memberId)) + .thenReturn(Optional.of(point)); + + // when + pointService.use(memberId, Money.of(3000)); + + // then + assertThat(point.getBalanceMoney()).isEqualTo(Money.of(7000)); + } + + @DisplayName("존재하지 않는 회원이면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNotFoundMember() { + // given + when(pointRepository.findByMemberId(999L)) + .thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, () -> + pointService.use(999L, Money.of(1000)) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("잔액이 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithInsufficientBalance() { + // given + Long memberId = 1L; + PointModel point = new PointModel(memberId); + point.charge(Money.of(1000)); + when(pointRepository.findByMemberId(memberId)) + .thenReturn(Optional.of(point)); + + // when + CoreException result = assertThrows(CoreException.class, () -> + pointService.use(memberId, Money.of(5000))); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("회원의 포인트를 조회할 때,") + @Nested + class GetByMemberId { + + @DisplayName("존재하는 회원이면, 포인트가 반환된다.") + @Test + void getByMemberIdSuccess() { + // given + Long memberId = 1L; + PointModel point = new PointModel(memberId); + when(pointRepository.findByMemberId(memberId)) + .thenReturn(Optional.of(point)); + + // when + PointModel result = pointService.getByMemberId(memberId); + + // then + assertThat(result.getMemberId()).isEqualTo(memberId); + } + + @DisplayName("존재하지 않는 회원이면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNotFoundMember() { + // given + when(pointRepository.findByMemberId(999L)) + .thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, () -> + pointService.getByMemberId(999L) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} + + + 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..129caffa9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,198 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ProductModelTest { + + @DisplayName("상품을 생성할 때,") + @Nested + class Create { + + @DisplayName("모든 정보가 유효하면, 정상적으로 생성된다.") + @Test + void createProduct() { + // given + Long brandId = 1L; + String name = "에어맥스"; + String description = "러닝화"; + int price = 129000; + int stockQuantity = 100; + String imageUrl = "https://example.com/airmax.png"; + + // when + ProductModel product = new ProductModel(brandId, name, description, price, stockQuantity, imageUrl); + + // then + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(brandId), + () -> assertThat(product.getName()).isEqualTo(name), + () -> assertThat(product.getDescription()).isEqualTo(description), + () -> assertThat(product.getPrice()).isEqualTo(price), + () -> assertThat(product.getStockQuantity()).isEqualTo(stockQuantity), + () -> assertThat(product.getImageUrl()).isEqualTo(imageUrl) + ); + } + + @DisplayName("브랜드 ID가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullBrandId() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new ProductModel(null, "에어맥스", "러닝화", 129000, 100, "url") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품명이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNullName() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new ProductModel(1L, null, "러닝화", 129000, 100, "url") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품명이 빈값이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithEmptyName() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new ProductModel(1L, " ", "러닝화", 129000, 100, "url") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNegativePrice() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new ProductModel(1L, "에어맥스", "러닝화", -1, 100, "url") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고가 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithNegativeStock() { + // given & when + CoreException result = assertThrows(CoreException.class, () -> + new ProductModel(1L, "에어맥스", "러닝화", 129000, -1, "url") + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고를 차감할 때,") + @Nested + class DecreaseStock { + + @DisplayName("충분한 재고가 있으면, 정상적으로 차감된다.") + @Test + void decreaseStockSuccess() { + // given + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, "url"); + + // when + product.decreaseStock(10); + + // then + assertThat(product.getStockQuantity()).isEqualTo(90); + } + + @DisplayName("재고와 동일한 수량을 차감하면, 재고가 0이 된다.") + @Test + void decreaseStockToZero() { + // given + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, "url"); + + // when + product.decreaseStock(100); + + // then + assertThat(product.getStockQuantity()).isEqualTo(0); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithInsufficientStock() { + // given + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 5, "url"); + + // when + CoreException result = assertThrows(CoreException.class, () -> + product.decreaseStock(10) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("차감 수량이 0 이하이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithZeroQuantity() { + // given + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, null); + + // when + CoreException result = assertThrows(CoreException.class, () -> + product.decreaseStock(0) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고를 증가시킬 때,") + @Nested + class IncreaseStock { + + @DisplayName("양수 수량이면, 정상적으로 증가된다.") + @Test + void increaseStockSuccess() { + // given + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, null); + + // when + product.increaseStock(50); + + // then + assertThat(product.getStockQuantity()).isEqualTo(150); + } + + @DisplayName("증가 수량이 0 이하이면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithZeroQuantity() { + // given + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, null); + + // when + CoreException result = assertThrows(CoreException.class, () -> + product.increaseStock(0) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceUnitTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceUnitTest.java new file mode 100644 index 000000000..a3574cca3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceUnitTest.java @@ -0,0 +1,145 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class ProductServiceUnitTest { + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private ProductService productService; + + @DisplayName("상품을 등록할 때,") + @Nested + class Register { + + @DisplayName("유효한 정보면, 정상적으로 등록된다.") + @Test + void registerSuccess() { + // given + when(productRepository.save(any(ProductModel.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + ProductModel result = productService.register(1L, "에어맥스", "러닝화", 129000, 100, "url"); + + // then + assertAll( + () -> assertThat(result.getBrandId()).isEqualTo(1L), + () -> assertThat(result.getName()).isEqualTo("에어맥스"), + () -> assertThat(result.getPrice()).isEqualTo(129000), + () -> assertThat(result.getStockQuantity()).isEqualTo(100) + ); + verify(productRepository, times(1)).save(any(ProductModel.class)); + } + } + + @DisplayName("상품을 조회할 때,") + @Nested + class GetById { + + @DisplayName("존재하는 ID면, 상품이 반환된다.") + @Test + void getByIdSuccess() { + // given + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, "url"); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + + // when + ProductModel result = productService.getById(1L); + + // then + assertThat(result.getName()).isEqualTo("에어맥스"); + } + + @DisplayName("존재하지 않는 ID면, NOT_FOUND 예외가 발생한다.") + @Test + void failWithNotFoundId() { + // given + when(productRepository.findById(999L)).thenReturn(Optional.empty()); + + // when + CoreException result = assertThrows(CoreException.class, () -> + productService.getById(999L) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드별 상품을 조회할 때,") + @Nested + class GetByBrandId { + + @DisplayName("해당 브랜드의 상품 목록이 반환된다.") + @Test + void getByBrandIdSuccess() { + // given + List products = List.of(new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, "url"), + new ProductModel(1L, "에어포스", "캐주얼화", 109000, 50, "url") + ); + when(productRepository.findAllByBrandId(1L)).thenReturn(products); + + // when + List result = productService.getByBrandId(1L); + + // then + assertThat(result).hasSize(2); + } + } + + @DisplayName("재고를 차감할 때,") + @Nested + class DecreaseStock { + + @DisplayName("충분한 재고가 있으면, 정상적으로 차감된다.") + @Test + void decreaseStockSuccess() { + // given + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 100, "url"); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + + // when + productService.decreaseStock(1L, 10); + + // then + assertThat(product.getStockQuantity()).isEqualTo(90); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void failWithInsufficientStock() { + // given + ProductModel product = new ProductModel(1L, "에어맥스", "러닝화", 129000, 5, "url"); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + + // when + CoreException result = assertThrows(CoreException.class, () -> + productService.decreaseStock(1L, 10) + ); + + // then + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/vo/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/vo/MoneyTest.java new file mode 100644 index 000000000..361e13aa1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/vo/MoneyTest.java @@ -0,0 +1,128 @@ +package com.loopers.domain.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class MoneyTest { + + @DisplayName("Money를 생성할 때,") + @Nested + class Create { + + @DisplayName("양수 금액이면, 정상적으로 생성된다.") + @Test + void createSuccess() { + // given & when + Money money = Money.of(1000); + + // then + assertThat(money.amount()).isEqualByComparingTo(BigDecimal.valueOf(1000)); + } + + @DisplayName("0이면, 정상적으로 생성된다.") + @Test + void createZero() { + // given & when + Money money = Money.zero(); + + // then + assertThat(money.amount()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @DisplayName("음수 금액이면, 예외가 발생한다.") + @Test + void failWithNegativeAmount() { + // given & when & then + assertThrows(IllegalArgumentException.class, () -> + new Money(BigDecimal.valueOf(-1))); + } + + @DisplayName("null이면, 예외가 발생한다.") + @Test + void failWithNull() { + // given & when & then + assertThrows(IllegalArgumentException.class, () -> + new Money(null)); + } + } + + @DisplayName("Money 연산을 수행할 때,") + @Nested + class Operations { + + @DisplayName("plus: 두 금액을 더한다.") + @Test + void plus() { + // given + Money a = Money.of(1000); + Money b = Money.of(2000); + + // when + Money result = a.plus(b); + + // then + assertThat(result.amount()).isEqualByComparingTo(BigDecimal.valueOf(3000)); + } + + @DisplayName("minus: 두 금액을 뺀다.") + @Test + void minus() { + // given + Money a = Money.of(3000); + Money b = Money.of(1000); + + // when + Money result = a.minus(b); + + // then + assertThat(result.amount()).isEqualByComparingTo(BigDecimal.valueOf(2000)); + } + + @DisplayName("minus: 결과가 음수면 예외가 발생한다.") + @Test + void minusFailWithNegativeResult() { + // given + Money a = Money.of(1000); + Money b = Money.of(3000); + + // when & then + assertThrows(IllegalArgumentException.class, () -> a.minus(b)); + } + + @DisplayName("isGreaterThanOrEqual: 크거나 같으면 true를 반환한다.") + @Test + void isGreaterThanOrEqual() { + // given + Money big = Money.of(5000); + Money small = Money.of(3000); + Money same = Money.of(5000); + + // when & then + assertThat(big.isGreaterThanOrEqual(small)).isTrue(); + assertThat(big.isGreaterThanOrEqual(same)).isTrue(); + assertThat(small.isGreaterThanOrEqual(big)).isFalse(); + } + } + + @DisplayName("VO 동등성을 확인할 때,") + @Nested + class Equality { + + @DisplayName("같은 금액이면 동일한 객체로 간주한다.") + @Test + void equalByValue() { + // given + Money a = Money.of(1000); + Money b = Money.of(1000); + + // when & then + assertThat(a).isEqualTo(b); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba65..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -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 ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // 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().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // 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.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // 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) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java new file mode 100644 index 000000000..89b963bf8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberV1ApiE2ETest.java @@ -0,0 +1,215 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.member.MemberFacade; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class MemberV1ApiE2ETest { + + private static final String ENDPOINT_REGISTER = "/api/v1/members"; + private static final String ENDPOINT_ME = "/api/v1/members/me"; + private static final String ENDPOINT_CHANGE_PASSWORD = "/api/v1/members/me/password"; + + private final TestRestTemplate testRestTemplate; + private final MemberFacade memberFacade; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberV1ApiE2ETest( + TestRestTemplate testRestTemplate, + MemberFacade memberFacade, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.memberFacade = memberFacade; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders authHeaders(String loginId, String loginPw) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", loginPw); + return headers; + } + + @DisplayName("POST /api/v1/members") + @Nested + class Register { + + @DisplayName("유효한 정보로 회원가입하면, 200 응답을 받는다.") + @Test + void registerSuccess() { + // given + MemberV1Dto.RegisterRequest request = new MemberV1Dto.RegisterRequest( + "testuser", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + + @DisplayName("중복된 loginId로 회원가입하면, 409 CONFLICT 응답을 받는다.") + @Test + void failWithDuplicateLoginId() { + // given + memberFacade.register("testuser", "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + MemberV1Dto.RegisterRequest request = new MemberV1Dto.RegisterRequest( + "testuser", "other1234!@", "김철수", LocalDate.of(1995, 3, 10), "other@example.com" + ); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), responseType + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + } + + @DisplayName("GET /api/v1/members/me") + @Nested + class GetMyInfo { + + @DisplayName("유효한 인증 정보로 조회하면, 마스킹된 이름이 포함된 200 응답을 받는다.") + @Test + void getMyInfoSuccess() { + // given + String loginId = "testuser"; + String password = "password1!@"; + memberFacade.register(loginId, password, "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + HttpHeaders headers = authHeaders(loginId, password); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType + ); + + // then + assertAll( + () -> assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(), + () -> assertThat(response.getBody().data().loginId()).isEqualTo(loginId), + () -> assertThat(response.getBody().data().maskedName()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo(LocalDate.of(2000, 6, 5)), + () -> assertThat(response.getBody().data().email()).isEqualTo("test@example.com") + ); + } + + @DisplayName("잘못된 비밀번호로 조회하면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void failWithWrongPassword() { + // given + String loginId = "testuser"; + memberFacade.register(loginId, "password1!@", "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + HttpHeaders headers = authHeaders(loginId, "wrongpass1!@"); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), responseType + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("인증 헤더 없이 조회하면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void failWithoutAuthHeaders() { + // given & when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(null), responseType + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("PATCH /api/v1/members/me/password") + @Nested + class ChangePassword { + + @DisplayName("유효한 인증 정보와 새 비밀번호로 변경하면, 200 응답을 받는다.") + @Test + void changePasswordSuccess() { + // given + String loginId = "testuser"; + String currentPassword = "password1!@"; + String newPassword = "newpass1!@#"; + memberFacade.register(loginId, currentPassword, "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + HttpHeaders headers = authHeaders(loginId, currentPassword); + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest(newPassword); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType + ); + + // then + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } + + @DisplayName("현재 비밀번호와 동일한 비밀번호로 변경하면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void failWithSamePassword() { + // given + String loginId = "testuser"; + String password = "password1!@"; + memberFacade.register(loginId, password, "홍길동", LocalDate.of(2000, 6, 5), "test@example.com"); + + HttpHeaders headers = authHeaders(loginId, password); + MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest(password); + + // when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/http/commerce-api/member-v1.http b/http/commerce-api/member-v1.http new file mode 100644 index 000000000..b7a53461b --- /dev/null +++ b/http/commerce-api/member-v1.http @@ -0,0 +1,26 @@ +### 회원가입 +POST {{commerce-api}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testuser", + "password": "password1!@", + "name": "홍길동", + "birthDate": "2000-06-05", + "email": "test@example.com" +} + +### 내 정보 조회 +GET {{commerce-api}}/api/v1/members/me +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: password1!@ + +### 비밀번호 수정 +PATCH {{commerce-api}}/api/v1/members/me/password +Content-Type: application/json +X-Loopers-LoginId: testuser +X-Loopers-LoginPw: password1!@ + +{ + "newPassword": "newpass1!@#" +}