diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 9d7d11d8c..a7c74c283 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -19,6 +19,9 @@ dependencies { annotationProcessor("jakarta.persistence:jakarta.persistence-api") annotationProcessor("jakarta.annotation:jakarta.annotation-api") + // architecture test + testImplementation("com.tngtech.archunit:archunit-junit5:1.4.0") + // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/PagedInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/PagedInfo.java new file mode 100644 index 000000000..0d590a8e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/PagedInfo.java @@ -0,0 +1,11 @@ +package com.loopers.application; + +import java.util.List; + +public record PagedInfo( + List content, + long totalElements, + int totalPages, + int page, + int size +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.java new file mode 100644 index 000000000..db67e7263 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressFacade.java @@ -0,0 +1,61 @@ +package com.loopers.application.address; + +import com.loopers.domain.address.Address; +import com.loopers.domain.address.AddressService; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class AddressFacade { + + private final AddressService addressService; + private final MemberService memberService; + + public AddressInfo register(String loginId, String label, String recipientName, String recipientPhone, + String zipCode, String address1, String address2) { + Long memberId = getMemberId(loginId); + Address address = addressService.register(memberId, label, recipientName, recipientPhone, + zipCode, address1, address2); + return AddressInfo.from(address); + } + + public AddressInfo getAddress(String loginId, Long addressId) { + Long memberId = getMemberId(loginId); + Address address = addressService.getAddress(addressId, memberId); + return AddressInfo.from(address); + } + + public List getAddresses(String loginId) { + Long memberId = getMemberId(loginId); + return addressService.getAddresses(memberId).stream() + .map(AddressInfo::from) + .toList(); + } + + public void update(String loginId, Long addressId, String label, String recipientName, String recipientPhone, + String zipCode, String address1, String address2) { + Long memberId = getMemberId(loginId); + addressService.update(addressId, memberId, label, recipientName, recipientPhone, + zipCode, address1, address2); + } + + public void delete(String loginId, Long addressId) { + Long memberId = getMemberId(loginId); + addressService.delete(addressId, memberId); + } + + public void changeDefault(String loginId, Long addressId) { + Long memberId = getMemberId(loginId); + addressService.changeDefault(addressId, memberId); + } + + private Long getMemberId(String loginId) { + Member member = memberService.getMemberByLoginId(loginId); + return member.getId(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/address/AddressInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressInfo.java new file mode 100644 index 000000000..fa9b1421d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/address/AddressInfo.java @@ -0,0 +1,20 @@ +package com.loopers.application.address; + +import com.loopers.domain.address.Address; + +public record AddressInfo(Long id, String label, String recipientName, String recipientPhone, + String zipCode, String address1, String address2, boolean isDefault) { + + public static AddressInfo from(Address address) { + return new AddressInfo( + address.getId(), + address.getLabel(), + address.getRecipientName(), + address.getRecipientPhone(), + address.getZipCode(), + address.getAddress1(), + address.getAddress2(), + address.getIsDefault() + ); + } +} 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..134ed7823 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,49 @@ +package com.loopers.application.brand; + +import com.loopers.application.PagedInfo; +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + private final ProductService productService; + + public BrandInfo register(String name, String description) { + Brand brand = brandService.register(name, description); + return BrandInfo.from(brand); + } + + public BrandInfo getBrand(Long id) { + Brand brand = brandService.getBrand(id); + return BrandInfo.from(brand); + } + + public PagedInfo getBrands(String keyword, int page, int size) { + PageResult result = brandService.getBrands(keyword, page, size); + return new PagedInfo<>( + result.content().stream().map(BrandInfo::from).toList(), + result.totalElements(), + result.totalPages(), + result.page(), + result.size() + ); + } + + public void update(Long id, String name, String description) { + brandService.update(id, name, description); + } + + @Transactional + public void delete(Long id) { + brandService.delete(id); + productService.deleteAllByBrandId(id); + } +} 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..088b7e50e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,14 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; + +public record BrandInfo(Long id, String name, String description, int likeCount) { + public static BrandInfo from(Brand brand) { + return new BrandInfo( + brand.getId(), + brand.getName(), + brand.getDescription(), + brand.getLikeCount() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/BrandLikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/BrandLikeInfo.java new file mode 100644 index 000000000..ba6e807a9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/BrandLikeInfo.java @@ -0,0 +1,19 @@ +package com.loopers.application.like; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; + +import java.time.ZonedDateTime; + +public record BrandLikeInfo( + BrandInfo brand, + ZonedDateTime likedAt +) { + public static BrandLikeInfo of(Like like, Brand brand) { + return new BrandLikeInfo( + BrandInfo.from(brand), + like.getCreatedAt() + ); + } +} 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..f4978b1c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,112 @@ +package com.loopers.application.like; + +import com.loopers.application.PagedInfo; +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.like.LikeTargetType; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final MemberService memberService; + private final ProductService productService; + private final BrandService brandService; + private final LikeService likeService; + + @Transactional + public LikeToggleInfo toggleProductLike(String loginId, Long productId) { + Long memberId = getMemberId(loginId); + productService.getProduct(productId); + + boolean liked = likeService.toggleLike(memberId, LikeTargetType.PRODUCT, productId); + + int likeCount = liked + ? productService.increaseLikeCount(productId) + : productService.decreaseLikeCount(productId); + + return new LikeToggleInfo(liked, likeCount); + } + + @Transactional + public LikeToggleInfo toggleBrandLike(String loginId, Long brandId) { + Long memberId = getMemberId(loginId); + brandService.getBrand(brandId); + + boolean liked = likeService.toggleLike(memberId, LikeTargetType.BRAND, brandId); + + int likeCount = liked + ? brandService.increaseLikeCount(brandId) + : brandService.decreaseLikeCount(brandId); + + return new LikeToggleInfo(liked, likeCount); + } + + @Transactional(readOnly = true) + public PagedInfo getMyLikedProducts(String loginId, int page, int size) { + Long memberId = getMemberId(loginId); + PageResult likes = likeService.getMyLikes(memberId, LikeTargetType.PRODUCT, page, size); + + List productIds = likes.content().stream() + .map(Like::getTargetId).toList(); + Map productMap = productService.getProductsByIds(productIds).stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + + List brandIds = productMap.values().stream() + .map(Product::getBrandId).distinct().toList(); + Map brandMap = brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + List content = likes.content().stream() + .filter(like -> productMap.containsKey(like.getTargetId())) + .map(like -> { + Product product = productMap.get(like.getTargetId()); + Brand brand = brandMap.get(product.getBrandId()); + return ProductLikeInfo.of(like, product, brand); + }) + .toList(); + + return new PagedInfo<>(content, likes.totalElements(), likes.totalPages(), likes.page(), likes.size()); + } + + @Transactional(readOnly = true) + public PagedInfo getMyLikedBrands(String loginId, int page, int size) { + Long memberId = getMemberId(loginId); + PageResult likes = likeService.getMyLikes(memberId, LikeTargetType.BRAND, page, size); + + List brandIds = likes.content().stream() + .map(Like::getTargetId).toList(); + Map brandMap = brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + List content = likes.content().stream() + .filter(like -> brandMap.containsKey(like.getTargetId())) + .map(like -> { + Brand brand = brandMap.get(like.getTargetId()); + return BrandLikeInfo.of(like, brand); + }) + .toList(); + + return new PagedInfo<>(content, likes.totalElements(), likes.totalPages(), likes.page(), likes.size()); + } + + private Long getMemberId(String loginId) { + Member member = memberService.getMemberByLoginId(loginId); + return member.getId(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeToggleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeToggleInfo.java new file mode 100644 index 000000000..c23e5a89d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeToggleInfo.java @@ -0,0 +1,4 @@ +package com.loopers.application.like; + +public record LikeToggleInfo(boolean liked, int likeCount) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeInfo.java new file mode 100644 index 000000000..c73005498 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/ProductLikeInfo.java @@ -0,0 +1,21 @@ +package com.loopers.application.like; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; + +import java.time.ZonedDateTime; + +public record ProductLikeInfo( + ProductInfo product, + ZonedDateTime likedAt +) { + public static ProductLikeInfo of(Like like, Product product, Brand brand) { + return new ProductLikeInfo( + ProductInfo.of(product, BrandInfo.from(brand)), + like.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/member/AdminMemberInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/member/AdminMemberInfo.java new file mode 100644 index 000000000..2fd19ce73 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/member/AdminMemberInfo.java @@ -0,0 +1,30 @@ +package com.loopers.application.member; + +import com.loopers.domain.member.Member; + +import java.time.LocalDate; +import java.time.ZonedDateTime; + +public record AdminMemberInfo( + Long memberId, + String loginId, + String name, + LocalDate birthDate, + String gender, + String email, + String phone, + ZonedDateTime createdAt +) { + public static AdminMemberInfo from(Member member) { + return new AdminMemberInfo( + member.getId(), + member.getLoginId(), + member.getName(), + member.getBirthDate(), + member.getGender().name(), + member.getEmail(), + member.getPhone(), + member.getCreatedAt() + ); + } +} 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 index 83ff27ad4..1b36856f3 100644 --- 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 @@ -1,5 +1,8 @@ package com.loopers.application.member; +import com.loopers.application.PagedInfo; +import com.loopers.domain.PageResult; +import com.loopers.domain.member.Gender; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberService; import lombok.RequiredArgsConstructor; @@ -13,17 +16,47 @@ public class MemberFacade { private final MemberService memberService; + public boolean authenticate(String loginId, String rawPassword) { + return memberService.authenticate(loginId, rawPassword); + } + public MemberInfo register(String loginId, String rawPassword, String name, - LocalDate birthDate, String email) { - Member member = memberService.register(loginId, rawPassword, name, birthDate, email); + LocalDate birthDate, String gender, String email, String phone) { + Gender genderEnum = Gender.valueOf(gender); + Member member = memberService.register(loginId, rawPassword, name, birthDate, genderEnum, email, phone); return MemberInfo.from(member); } - public MemberInfo getMe(Member authenticatedMember) { - return MemberInfo.fromWithMaskedName(authenticatedMember); + public MemberInfo getMe(String loginId) { + Member member = memberService.getMemberByLoginId(loginId); + return MemberInfo.fromWithMaskedName(member); + } + + public void changePassword(String loginId, String currentPassword, String newPassword) { + memberService.changePassword(loginId, currentPassword, newPassword); + } + + public void updatePhone(String loginId, String phone) { + memberService.updatePhone(loginId, phone); + } + + public void withdraw(String loginId, String rawPassword) { + memberService.withdraw(loginId, rawPassword); + } + + public PagedInfo getMembersForAdmin(String keyword, int page, int size) { + PageResult result = memberService.getMembers(keyword, page, size); + return new PagedInfo<>( + result.content().stream().map(AdminMemberInfo::from).toList(), + result.totalElements(), + result.totalPages(), + result.page(), + result.size() + ); } - public void changePassword(Member authenticatedMember, String currentPassword, String newPassword) { - memberService.changePassword(authenticatedMember, currentPassword, newPassword); + public AdminMemberInfo getMemberForAdmin(Long memberId) { + Member member = memberService.getMember(memberId); + return AdminMemberInfo.from(member); } } 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 index d6078e7ae..2abb10b17 100644 --- 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 @@ -4,13 +4,15 @@ import java.time.LocalDate; -public record MemberInfo(String loginId, String name, LocalDate birthDate, String email) { +public record MemberInfo(String loginId, String name, LocalDate birthDate, String gender, String email, String phone) { public static MemberInfo from(Member member) { return new MemberInfo( member.getLoginId(), member.getName(), member.getBirthDate(), - member.getEmail() + member.getGender().name(), + member.getEmail(), + member.getPhone() ); } @@ -19,7 +21,9 @@ public static MemberInfo fromWithMaskedName(Member member) { member.getLoginId(), maskName(member.getName()), member.getBirthDate(), - member.getEmail() + member.getGender().name(), + member.getEmail(), + member.getPhone() ); } 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..1e00bd554 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,149 @@ +package com.loopers.application.order; + +import com.loopers.application.PagedInfo; +import com.loopers.domain.PageResult; +import com.loopers.domain.address.Address; +import com.loopers.domain.address.AddressService; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final MemberService memberService; + private final ProductService productService; + private final OrderService orderService; + private final AddressService addressService; + + @Transactional + public OrderInfo createOrder(String loginId, Long addressId, List itemRequests) { + Long memberId = getMemberId(loginId); + + // 1. 배송지 검증 + 스냅샷 + Address address = addressService.getAddress(addressId, memberId); + + // 2. 주문 항목 검증 + 중복 상품 합산 (도메인 규칙) + List serviceRequests = itemRequests.stream() + .map(r -> new OrderService.OrderItemRequest(r.productId(), r.quantity())) + .toList(); + Map mergedItems = orderService.mergeOrderItems(serviceRequests); + + // 3. 상품 검증 + 재고 차감 + totalAmount 계산 + long totalAmount = 0L; + List commands = new java.util.ArrayList<>(); + + for (Map.Entry entry : mergedItems.entrySet()) { + Long productId = entry.getKey(); + int quantity = entry.getValue(); + + Product product = productService.getProduct(productId); + product.validateOrderQuantity(quantity); + product.decreaseStock(quantity); + + totalAmount += product.getPrice() * quantity; + commands.add(new OrderService.OrderItemCommand( + productId, product.getName(), product.getPrice(), quantity + )); + } + + // 4. 주문 생성 + Order order = orderService.createOrder( + memberId, address.getRecipientName(), address.getRecipientPhone(), + address.getZipCode(), address.getAddress1(), address.getAddress2(), totalAmount + ); + + // 5. 주문 항목 생성 + List items = orderService.createOrderItems(order.getId(), commands); + + return OrderInfo.of(order, items); + } + + @Transactional + public void cancelOrder(String loginId, Long orderId) { + Long memberId = getMemberId(loginId); + + // 소유권 검증 + 취소 상태 체크 + 상태 전이 (도메인 규칙) + List items = orderService.cancelOrder(orderId, memberId); + + // 재고 복원 (cross-domain orchestration) + for (OrderItem item : items) { + Product product = productService.getProduct(item.getProductId()); + product.increaseStock(item.getQuantity()); + } + } + + @Transactional + public OrderInfo updateShippingAddress(String loginId, Long orderId, + String recipientName, String recipientPhone, + String zipCode, String address1, String address2) { + Long memberId = getMemberId(loginId); + Order order = orderService.getOrderForMember(orderId, memberId); + + order.updateShippingAddress(recipientName, recipientPhone, zipCode, address1, address2); + + List items = orderService.getOrderItems(orderId); + return OrderInfo.of(order, items); + } + + @Transactional(readOnly = true) + public OrderInfo getOrder(String loginId, Long orderId) { + Long memberId = getMemberId(loginId); + Order order = orderService.getOrderForMember(orderId, memberId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.of(order, items); + } + + @Transactional(readOnly = true) + public PagedInfo getMyOrders(String loginId, LocalDate startAt, LocalDate endAt, + int page, int size) { + Long memberId = getMemberId(loginId); + PageResult result = orderService.getMyOrders(memberId, startAt, endAt, page, size); + return toPagedSummary(result); + } + + @Transactional(readOnly = true) + public OrderInfo getOrderForAdmin(Long orderId) { + Order order = orderService.getOrder(orderId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.of(order, items); + } + + @Transactional(readOnly = true) + public PagedInfo getOrdersForAdmin(Long memberId, int page, int size) { + PageResult result = orderService.getOrders(memberId, page, size); + return toPagedSummary(result); + } + + private PagedInfo toPagedSummary(PageResult result) { + List orderIds = result.content().stream().map(Order::getId).toList(); + Map> itemMap = orderService.getOrderItemsByOrderIds(orderIds).stream() + .collect(Collectors.groupingBy(OrderItem::getOrderId)); + + List summaries = result.content().stream() + .map(order -> OrderSummaryInfo.of(order, itemMap.getOrDefault(order.getId(), List.of()))) + .toList(); + + return new PagedInfo<>(summaries, result.totalElements(), result.totalPages(), result.page(), result.size()); + } + + private Long getMemberId(String loginId) { + Member member = memberService.getMemberByLoginId(loginId); + return member.getId(); + } + + 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..0d524f9f7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,37 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderInfo( + Long id, + Long memberId, + String recipientName, + String recipientPhone, + String zipCode, + String address1, + String address2, + Long totalAmount, + String status, + List items, + ZonedDateTime createdAt +) { + public static OrderInfo of(Order order, List items) { + return new OrderInfo( + order.getId(), + order.getMemberId(), + order.getRecipientName(), + order.getRecipientPhone(), + order.getZipCode(), + order.getAddress1(), + order.getAddress2(), + order.getTotalAmount(), + order.getStatus().name(), + items.stream().map(OrderItemInfo::from).toList(), + order.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..8d3723298 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; + +public record OrderItemInfo( + Long id, + Long productId, + String productName, + Long productPrice, + int quantity, + Long subtotal +) { + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getId(), + item.getProductId(), + item.getProductName(), + item.getProductPrice(), + item.getQuantity(), + item.getSubtotal() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderSummaryInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderSummaryInfo.java new file mode 100644 index 000000000..1a19b965a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderSummaryInfo.java @@ -0,0 +1,28 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderSummaryInfo( + Long id, + Long totalAmount, + String status, + int itemCount, + String representativeProductName, + ZonedDateTime createdAt +) { + public static OrderSummaryInfo of(Order order, List items) { + String representativeName = items.isEmpty() ? "" : items.get(0).getProductName(); + return new OrderSummaryInfo( + order.getId(), + order.getTotalAmount(), + order.getStatus().name(), + items.size(), + representativeName, + order.getCreatedAt() + ); + } +} 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..e086edd2e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,68 @@ +package com.loopers.application.product; + +import com.loopers.application.PagedInfo; +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSortType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + + public ProductInfo register(Long brandId, String name, String description, Long price, int stockQuantity, int maxOrderQuantity) { + Brand brand = brandService.getBrand(brandId); + Product product = productService.register(brandId, name, description, price, stockQuantity, maxOrderQuantity); + return ProductInfo.of(product, BrandInfo.from(brand)); + } + + public ProductInfo getProduct(Long id) { + Product product = productService.getProduct(id); + Brand brand = brandService.getBrand(product.getBrandId()); + return ProductInfo.of(product, BrandInfo.from(brand)); + } + + public PagedInfo getProducts(String keyword, Long brandId, String sort, int page, int size) { + ProductSortType sortType = ProductSortType.valueOf(sort); + PageResult result = productService.getProducts(keyword, brandId, sortType, page, size); + List brandIds = result.content().stream() + .map(Product::getBrandId).distinct().toList(); + Map brandMap = brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + return new PagedInfo<>( + result.content().stream().map(product -> { + Brand brand = brandMap.get(product.getBrandId()); + return ProductInfo.of(product, BrandInfo.from(brand)); + }).toList(), + result.totalElements(), + result.totalPages(), + result.page(), + result.size() + ); + } + + public void update(Long id, String name, String description, Long price, int maxOrderQuantity) { + productService.update(id, name, description, price, maxOrderQuantity); + } + + public void delete(Long id) { + productService.delete(id); + } + + public void updateStock(Long id, int quantity) { + productService.updateStock(id, quantity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..b5b43d906 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,28 @@ +package com.loopers.application.product; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.product.Product; + +public record ProductInfo( + Long id, + String name, + String description, + Long price, + int stockQuantity, + int maxOrderQuantity, + int likeCount, + BrandInfo brand +) { + public static ProductInfo of(Product product, BrandInfo brandInfo) { + return new ProductInfo( + product.getId(), + product.getName(), + product.getDescription(), + product.getPrice(), + product.getStockQuantity(), + product.getMaxOrderQuantity(), + product.getLikeCount(), + brandInfo + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/PageResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/PageResult.java new file mode 100644 index 000000000..914b12e63 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/PageResult.java @@ -0,0 +1,11 @@ +package com.loopers.domain; + +import java.util.List; + +public record PageResult( + List content, + long totalElements, + int totalPages, + int page, + int size +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java b/apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java new file mode 100644 index 000000000..2f6b9de4b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/address/Address.java @@ -0,0 +1,125 @@ +package com.loopers.domain.address; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "address") +public class Address extends BaseEntity { + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false) + private String label; + + @Column(nullable = false) + private String recipientName; + + @Column(nullable = false) + private String recipientPhone; + + @Column(nullable = false) + private String zipCode; + + @Column(nullable = false) + private String address1; + + private String address2; + + @Column(nullable = false) + private boolean isDefault; + + protected Address() {} + + private Address(Long memberId, String label, String recipientName, String recipientPhone, + String zipCode, String address1, String address2, boolean isDefault) { + this.memberId = memberId; + this.label = label; + this.recipientName = recipientName; + this.recipientPhone = recipientPhone; + this.zipCode = zipCode; + this.address1 = address1; + this.address2 = address2; + this.isDefault = isDefault; + } + + public static Address create(Long memberId, String label, String recipientName, String recipientPhone, + String zipCode, String address1, String address2, boolean isDefault) { + validateNotBlank(label, "배송지명은 필수입니다."); + validateNotBlank(recipientName, "수령인 이름은 필수입니다."); + validateNotBlank(recipientPhone, "수령인 전화번호는 필수입니다."); + validateNotBlank(zipCode, "우편번호는 필수입니다."); + validateNotBlank(address1, "기본주소는 필수입니다."); + return new Address(memberId, label, recipientName, recipientPhone, zipCode, address1, address2, isDefault); + } + + public void update(String label, String recipientName, String recipientPhone, + String zipCode, String address1, String address2) { + validateNotBlank(label, "배송지명은 필수입니다."); + validateNotBlank(recipientName, "수령인 이름은 필수입니다."); + validateNotBlank(recipientPhone, "수령인 전화번호는 필수입니다."); + validateNotBlank(zipCode, "우편번호는 필수입니다."); + validateNotBlank(address1, "기본주소는 필수입니다."); + this.label = label; + this.recipientName = recipientName; + this.recipientPhone = recipientPhone; + this.zipCode = zipCode; + this.address1 = address1; + this.address2 = address2; + } + + @Override + public void delete() { + if (this.isDefault) { + throw new CoreException(ErrorType.BAD_REQUEST, "기본 배송지는 삭제할 수 없습니다. 다른 배송지를 기본으로 변경 후 삭제해 주세요."); + } + super.delete(); + } + + public void changeDefault(boolean isDefault) { + this.isDefault = isDefault; + } + + private static void validateNotBlank(String value, String message) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + public Long getMemberId() { + return memberId; + } + + public String getLabel() { + return label; + } + + public String getRecipientName() { + return recipientName; + } + + public String getRecipientPhone() { + return recipientPhone; + } + + public String getZipCode() { + return zipCode; + } + + public String getAddress1() { + return address1; + } + + public String getAddress2() { + return address2; + } + + public boolean getIsDefault() { + return isDefault; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressReader.java new file mode 100644 index 000000000..02851ce85 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressReader.java @@ -0,0 +1,13 @@ +package com.loopers.domain.address; + +import java.util.List; +import java.util.Optional; + +public interface AddressReader { + + Optional
findByIdAndMemberId(Long id, Long memberId); + + List
findAllByMemberId(Long memberId); + + long countByMemberId(Long memberId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressRepository.java new file mode 100644 index 000000000..a20dd763f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.address; + +public interface AddressRepository { + + Address save(Address address); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.java b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.java new file mode 100644 index 000000000..2a1971b2c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/address/AddressService.java @@ -0,0 +1,68 @@ +package com.loopers.domain.address; + +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; + +@RequiredArgsConstructor +@Component +public class AddressService { + + private static final int MAX_ADDRESS_COUNT = 10; + + private final AddressReader addressReader; + private final AddressRepository addressRepository; + + @Transactional + public Address register(Long memberId, String label, String recipientName, String recipientPhone, + String zipCode, String address1, String address2) { + long count = addressReader.countByMemberId(memberId); + if (count >= MAX_ADDRESS_COUNT) { + throw new CoreException(ErrorType.BAD_REQUEST, "배송지는 최대 " + MAX_ADDRESS_COUNT + "개까지 등록할 수 있습니다."); + } + boolean isDefault = count == 0; + Address address = Address.create(memberId, label, recipientName, recipientPhone, + zipCode, address1, address2, isDefault); + return addressRepository.save(address); + } + + @Transactional(readOnly = true) + public Address getAddress(Long id, Long memberId) { + return addressReader.findByIdAndMemberId(id, memberId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "배송지를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List
getAddresses(Long memberId) { + return addressReader.findAllByMemberId(memberId); + } + + @Transactional + public void update(Long id, Long memberId, String label, String recipientName, String recipientPhone, + String zipCode, String address1, String address2) { + Address address = getAddress(id, memberId); + address.update(label, recipientName, recipientPhone, zipCode, address1, address2); + } + + @Transactional + public void delete(Long id, Long memberId) { + Address address = getAddress(id, memberId); + address.delete(); + } + + @Transactional + public void changeDefault(Long id, Long memberId) { + Address newDefault = getAddress(id, memberId); + List
addresses = addressReader.findAllByMemberId(memberId); + for (Address address : addresses) { + if (address.getIsDefault()) { + address.changeDefault(false); + } + } + newDefault.changeDefault(true); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..aead85e36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,68 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "brand") +public class Brand extends BaseEntity { + + @Column(unique = true, nullable = false) + private String name; + + private String description; + + @Column(nullable = false) + private int likeCount; + + protected Brand() {} + + private Brand(String name, String description) { + this.name = name; + this.description = description; + this.likeCount = 0; + } + + public static Brand create(String name, String description) { + validateNotBlank(name, "브랜드명은 필수입니다."); + return new Brand(name, description); + } + + public void update(String name, String description) { + validateNotBlank(name, "브랜드명은 필수입니다."); + this.name = name; + this.description = description; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + private static void validateNotBlank(String value, String message) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public int getLikeCount() { + return likeCount; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.java new file mode 100644 index 000000000..1eacc3271 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandReader.java @@ -0,0 +1,14 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.PageResult; + +import java.util.List; +import java.util.Optional; + +public interface BrandReader { + Optional findById(Long id); + boolean existsById(Long id); + boolean existsByName(String name); + List findAllByIds(List ids); + PageResult findAll(String keyword, int page, int size); +} 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..61bccc0e1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.brand; + +public interface BrandRepository { + Brand save(Brand brand); +} 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..7cefc6d15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,71 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandReader brandReader; + private final BrandRepository brandRepository; + + @Transactional + public Brand register(String name, String description) { + if (brandReader.existsByName(name)) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 존재하는 브랜드명입니다."); + } + Brand brand = Brand.create(name, description); + return brandRepository.save(brand); + } + + @Transactional(readOnly = true) + public Brand getBrand(Long id) { + return brandReader.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + @Transactional + public void update(Long id, String name, String description) { + Brand brand = getBrand(id); + brand.update(name, description); + brandRepository.save(brand); + } + + @Transactional + public void delete(Long id) { + Brand brand = getBrand(id); + brand.delete(); + brandRepository.save(brand); + } + + @Transactional + public int increaseLikeCount(Long id) { + Brand brand = getBrand(id); + brand.increaseLikeCount(); + return brand.getLikeCount(); + } + + @Transactional + public int decreaseLikeCount(Long id) { + Brand brand = getBrand(id); + brand.decreaseLikeCount(); + return brand.getLikeCount(); + } + + @Transactional(readOnly = true) + public List getBrandsByIds(List ids) { + return brandReader.findAllByIds(ids); + } + + @Transactional(readOnly = true) + public PageResult getBrands(String keyword, int page, int size) { + return brandReader.findAll(keyword, page, size); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..56a9a24cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,80 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_like_member_target", + columnNames = {"member_id", "target_type", "target_id"}) +}) +public class Like { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false) + private LikeTargetType targetType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + protected Like() {} + + private Like(Long memberId, LikeTargetType targetType, Long targetId) { + this.memberId = memberId; + this.targetType = targetType; + this.targetId = targetId; + } + + public static Like create(Long memberId, LikeTargetType targetType, Long targetId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 필수입니다."); + } + if (targetType == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 대상 타입은 필수입니다."); + } + if (targetId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 대상 ID는 필수입니다."); + } + return new Like(memberId, targetType, targetId); + } + + @PrePersist + private void prePersist() { + ZonedDateTime now = ZonedDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + this.updatedAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeReader.java new file mode 100644 index 000000000..a2251c76f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeReader.java @@ -0,0 +1,10 @@ +package com.loopers.domain.like; + +import com.loopers.domain.PageResult; + +import java.util.Optional; + +public interface LikeReader { + Optional findByMemberIdAndTargetTypeAndTargetId(Long memberId, LikeTargetType targetType, Long targetId); + PageResult findAllByMemberIdAndTargetType(Long memberId, LikeTargetType targetType, int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..005223ec2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.like; + +public interface LikeRepository { + Like save(Like like); + void delete(Like like); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..ae2176644 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,35 @@ +package com.loopers.domain.like; + +import com.loopers.domain.PageResult; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeReader likeReader; + private final LikeRepository likeRepository; + + @Transactional + public boolean toggleLike(Long memberId, LikeTargetType targetType, Long targetId) { + Optional existing = likeReader.findByMemberIdAndTargetTypeAndTargetId(memberId, targetType, targetId); + + if (existing.isPresent()) { + likeRepository.delete(existing.get()); + return false; + } + + Like like = Like.create(memberId, targetType, targetId); + likeRepository.save(like); + return true; + } + + @Transactional(readOnly = true) + public PageResult getMyLikes(Long memberId, LikeTargetType targetType, int page, int size) { + return likeReader.findAllByMemberIdAndTargetType(memberId, targetType, page, size); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeTargetType.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeTargetType.java new file mode 100644 index 000000000..9557593d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeTargetType.java @@ -0,0 +1,6 @@ +package com.loopers.domain.like; + +public enum LikeTargetType { + PRODUCT, + BRAND +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Gender.java new file mode 100644 index 000000000..f0f8e89ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Gender.java @@ -0,0 +1,6 @@ +package com.loopers.domain.member; + +public enum Gender { + MALE, + FEMALE +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java index 70d1d50e0..bbab47713 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -5,6 +5,8 @@ import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Table; import java.time.LocalDate; @@ -18,26 +20,36 @@ public class Member extends BaseEntity { private String password; private String name; private LocalDate birthDate; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Gender gender; + private String email; + private String phone; protected Member() {} private Member(String loginId, String password, String name, - LocalDate birthDate, String email) { + LocalDate birthDate, Gender gender, String email, String phone) { this.loginId = loginId; this.password = password; this.name = name; this.birthDate = birthDate; + this.gender = gender; this.email = email; + this.phone = phone; } public static Member create(String loginId, String rawPassword, String name, LocalDate birthDate, - String email, PasswordEncoder encoder) { + Gender gender, String email, String phone, + PasswordEncoder encoder) { validateNotBlank(loginId, "로그인ID는 필수입니다."); validateNotBlank(rawPassword, "비밀번호는 필수입니다."); validateNotBlank(name, "이름은 필수입니다."); validateNotNull(birthDate, "생년월일은 필수입니다."); + validateNotNull(gender, "성별은 필수입니다."); validateNotBlank(email, "이메일은 필수입니다."); validateLoginId(loginId); @@ -45,9 +57,13 @@ public static Member create(String loginId, String rawPassword, String normalizedName = normalizeName(name); validateName(normalizedName); validateEmail(email); + if (phone != null && !phone.isBlank()) { + validatePhone(phone); + } String encodedPassword = encoder.encode(rawPassword); - return new Member(loginId, encodedPassword, normalizedName, birthDate, email); + return new Member(loginId, encodedPassword, normalizedName, birthDate, gender, email, + phone != null && !phone.isBlank() ? phone : null); } private static void validateNotNull(Object value, String message) { @@ -100,34 +116,53 @@ private static void validateEmail(String email) { } } + private static void validatePhone(String phone) { + if (!phone.matches("^010-\\d{4}-\\d{4}$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "전화번호 형식이 올바르지 않습니다. (010-XXXX-XXXX)"); + } + } + + public void updatePhone(String phone) { + if (phone != null && !phone.isBlank()) { + validatePhone(phone); + this.phone = phone; + } else { + this.phone = null; + } + } + + public void withdraw(String rawPassword, PasswordEncoder encoder) { + validateNotBlank(rawPassword, "비밀번호는 필수입니다."); + if (!encoder.matches(rawPassword, this.password)) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다."); + } + this.delete(); + } + public void changePassword(String currentPassword, String newRawPassword, PasswordEncoder encoder) { validateNotBlank(currentPassword, "현재 비밀번호는 필수입니다."); validateNotBlank(newRawPassword, "새 비밀번호는 필수입니다."); - // 현재 비밀번호 확인 if (!encoder.matches(currentPassword, this.password)) { throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); } - // 새 비밀번호가 현재와 동일한지 확인 if (encoder.matches(newRawPassword, this.password)) { throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); } - // 새 비밀번호 규칙 검증 validatePassword(newRawPassword, this.birthDate); - // 비밀번호 변경 this.password = encoder.encode(newRawPassword); } + public boolean verifyPassword(String rawPassword, PasswordEncoder encoder) { + return encoder.matches(rawPassword, this.password); + } + // Getter public String getLoginId() { return loginId; } - public String getPassword() { - return password; - } - public String getName() { return name; } @@ -136,7 +171,15 @@ public LocalDate getBirthDate() { return birthDate; } + public Gender getGender() { + return gender; + } + public String getEmail() { return email; } + + public String getPhone() { + return phone; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java index ef6b06605..120635540 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberReader.java @@ -1,8 +1,12 @@ package com.loopers.domain.member; +import com.loopers.domain.PageResult; + import java.util.Optional; public interface MemberReader { boolean existsByLoginId(String loginId); Optional findByLoginId(String loginId); + Optional findById(Long id); + PageResult findAll(String keyword, int page, int size); } 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 index 7ab5d49ae..3525e1102 100644 --- 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 @@ -1,9 +1,11 @@ package com.loopers.domain.member; +import com.loopers.domain.PageResult; 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; @@ -15,18 +17,56 @@ public class MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; + @Transactional public Member register(String loginId, String rawPassword, String name, - LocalDate birthDate, String email) { + LocalDate birthDate, Gender gender, String email, String phone) { if (memberReader.existsByLoginId(loginId)) { throw new CoreException(ErrorType.BAD_REQUEST, "이미 존재하는 로그인ID입니다."); } - Member member = Member.create(loginId, rawPassword, name, birthDate, email, passwordEncoder); + Member member = Member.create(loginId, rawPassword, name, birthDate, gender, email, phone, passwordEncoder); return memberRepository.save(member); } - public void changePassword(Member member, String currentPassword, String newPassword) { + @Transactional + public void changePassword(String loginId, String currentPassword, String newPassword) { + Member member = getMemberByLoginId(loginId); member.changePassword(currentPassword, newPassword, passwordEncoder); - memberRepository.save(member); + } + + @Transactional + public void updatePhone(String loginId, String phone) { + Member member = getMemberByLoginId(loginId); + member.updatePhone(phone); + } + + @Transactional + public void withdraw(String loginId, String rawPassword) { + Member member = getMemberByLoginId(loginId); + member.withdraw(rawPassword, passwordEncoder); + } + + @Transactional(readOnly = true) + public boolean authenticate(String loginId, String rawPassword) { + return memberReader.findByLoginId(loginId) + .filter(m -> m.verifyPassword(rawPassword, passwordEncoder)) + .isPresent(); + } + + @Transactional(readOnly = true) + public Member getMemberByLoginId(String loginId) { + return memberReader.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Member getMember(Long memberId) { + return memberReader.findById(memberId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public PageResult getMembers(String keyword, int page, int size) { + return memberReader.findAll(keyword, page, size); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..6164d42cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,138 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; + +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "recipient_name", nullable = false) + private String recipientName; + + @Column(name = "recipient_phone", nullable = false) + private String recipientPhone; + + @Column(name = "zip_code", nullable = false) + private String zipCode; + + @Column(name = "address1", nullable = false) + private String address1; + + @Column(name = "address2") + private String address2; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + protected Order() {} + + private Order(Long memberId, String recipientName, String recipientPhone, + String zipCode, String address1, String address2, Long totalAmount) { + this.memberId = memberId; + this.recipientName = recipientName; + this.recipientPhone = recipientPhone; + this.zipCode = zipCode; + this.address1 = address1; + this.address2 = address2; + this.totalAmount = totalAmount; + this.status = OrderStatus.COMPLETED; + } + + public static Order create(Long memberId, String recipientName, String recipientPhone, + String zipCode, String address1, String address2, Long totalAmount) { + validateNotNull(memberId, "회원 ID는 필수입니다."); + validateNotBlank(recipientName, "수령인 이름은 필수입니다."); + validateNotBlank(recipientPhone, "수령인 전화번호는 필수입니다."); + validateNotBlank(zipCode, "우편번호는 필수입니다."); + validateNotBlank(address1, "기본주소는 필수입니다."); + validatePositive(totalAmount, "주문 총액은 0보다 커야 합니다."); + return new Order(memberId, recipientName, recipientPhone, zipCode, address1, address2, totalAmount); + } + + public void cancel() { + if (this.status == OrderStatus.CANCELLED) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 취소된 주문입니다."); + } + this.status = OrderStatus.CANCELLED; + } + + public void updateShippingAddress(String recipientName, String recipientPhone, + String zipCode, String address1, String address2) { + if (this.status != OrderStatus.COMPLETED) { + throw new CoreException(ErrorType.BAD_REQUEST, "완료된 주문만 배송지를 수정할 수 있습니다."); + } + validateNotBlank(recipientName, "수령인 이름은 필수입니다."); + validateNotBlank(recipientPhone, "수령인 전화번호는 필수입니다."); + validateNotBlank(zipCode, "우편번호는 필수입니다."); + validateNotBlank(address1, "기본주소는 필수입니다."); + this.recipientName = recipientName; + this.recipientPhone = recipientPhone; + this.zipCode = zipCode; + this.address1 = address1; + this.address2 = address2; + } + + private static void validateNotNull(Object value, String message) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validateNotBlank(String value, String message) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validatePositive(Long value, String message) { + if (value == null || value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + public Long getMemberId() { + return memberId; + } + + public String getRecipientName() { + return recipientName; + } + + public String getRecipientPhone() { + return recipientPhone; + } + + public String getZipCode() { + return zipCode; + } + + public String getAddress1() { + return address1; + } + + public String getAddress2() { + return address2; + } + + public Long getTotalAmount() { + return totalAmount; + } + + public OrderStatus getStatus() { + return status; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..119bc735b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,96 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "order_item") +public class OrderItem extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "product_price", nullable = false) + private Long productPrice; + + @Column(name = "quantity", nullable = false) + private int quantity; + + protected OrderItem() {} + + private OrderItem(Long orderId, Long productId, String productName, Long productPrice, int quantity) { + this.orderId = orderId; + this.productId = productId; + this.productName = productName; + this.productPrice = productPrice; + this.quantity = quantity; + } + + public static OrderItem create(Long orderId, Long productId, String productName, + Long productPrice, int quantity) { + validateNotNull(orderId, "주문 ID는 필수입니다."); + validateNotNull(productId, "상품 ID는 필수입니다."); + validateNotBlank(productName, "상품명은 필수입니다."); + validatePositive(productPrice, "상품 가격은 0보다 커야 합니다."); + validatePositiveInt(quantity, "주문 수량은 0보다 커야 합니다."); + return new OrderItem(orderId, productId, productName, productPrice, quantity); + } + + public Long getSubtotal() { + return productPrice * quantity; + } + + private static void validateNotNull(Object value, String message) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validateNotBlank(String value, String message) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validatePositive(Long value, String message) { + if (value == null || value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validatePositiveInt(int value, String message) { + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + public Long getOrderId() { + return orderId; + } + + public Long getProductId() { + return productId; + } + + public String getProductName() { + return productName; + } + + public Long getProductPrice() { + return productPrice; + } + + public int getQuantity() { + return quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemReader.java new file mode 100644 index 000000000..ed901b287 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemReader.java @@ -0,0 +1,8 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemReader { + List findAllByOrderId(Long orderId); + List findAllByOrderIds(List orderIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..4725144df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + OrderItem save(OrderItem orderItem); + List saveAll(List orderItems); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java new file mode 100644 index 000000000..bb3001f76 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderReader.java @@ -0,0 +1,13 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; + +import java.time.LocalDate; +import java.util.Optional; + +public interface OrderReader { + Optional findById(Long id); + Optional findByIdAndMemberId(Long id, Long memberId); + PageResult findAllByMemberId(Long memberId, LocalDate startAt, LocalDate endAt, int page, int size); + PageResult findAll(Long memberId, int page, int size); +} 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..6588fa063 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.order; + +public interface OrderRepository { + Order save(Order order); +} 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..798fb4762 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,101 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderReader orderReader; + private final OrderItemRepository orderItemRepository; + private final OrderItemReader orderItemReader; + + @Transactional + public Order createOrder(Long memberId, String recipientName, String recipientPhone, + String zipCode, String address1, String address2, Long totalAmount) { + Order order = Order.create(memberId, recipientName, recipientPhone, zipCode, address1, address2, totalAmount); + return orderRepository.save(order); + } + + @Transactional + public List createOrderItems(Long orderId, List commands) { + List items = commands.stream() + .map(cmd -> OrderItem.create(orderId, cmd.productId(), cmd.productName(), cmd.productPrice(), cmd.quantity())) + .toList(); + return orderItemRepository.saveAll(items); + } + + @Transactional(readOnly = true) + public Order getOrder(Long orderId) { + return orderReader.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Order getOrderForMember(Long orderId, Long memberId) { + return orderReader.findByIdAndMemberId(orderId, memberId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long orderId) { + return orderItemReader.findAllByOrderId(orderId); + } + + @Transactional(readOnly = true) + public List getOrderItemsByOrderIds(List orderIds) { + return orderItemReader.findAllByOrderIds(orderIds); + } + + @Transactional(readOnly = true) + public PageResult getMyOrders(Long memberId, LocalDate startAt, LocalDate endAt, int page, int size) { + return orderReader.findAllByMemberId(memberId, startAt, endAt, page, size); + } + + @Transactional(readOnly = true) + public PageResult getOrders(Long memberId, int page, int size) { + return orderReader.findAll(memberId, page, size); + } + + public Map mergeOrderItems(List requests) { + if (requests == null || requests.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 1개 이상이어야 합니다."); + } + Map merged = new LinkedHashMap<>(); + for (OrderItemRequest request : requests) { + if (request.quantity() <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 0보다 커야 합니다."); + } + merged.merge(request.productId(), request.quantity(), Integer::sum); + } + return merged; + } + + public List cancelOrder(Long orderId, Long memberId) { + Order order = orderReader.findByIdAndMemberId(orderId, memberId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + + if (order.getStatus() == OrderStatus.CANCELLED) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + + order.cancel(); + + return orderItemReader.findAllByOrderId(orderId); + } + + public record OrderItemRequest(Long productId, int quantity) {} + + public record OrderItemCommand(Long productId, String productName, Long productPrice, int quantity) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..801c04965 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + COMPLETED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..df8317d69 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,143 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "product") +public class Product extends BaseEntity { + + @Column(nullable = false) + private Long brandId; + + @Column(nullable = false) + private String name; + + private String description; + + @Column(nullable = false) + private Long price; + + @Column(nullable = false) + private int stockQuantity; + + @Column(nullable = false) + private int maxOrderQuantity; + + @Column(nullable = false) + private int likeCount; + + protected Product() {} + + private Product(Long brandId, String name, String description, Long price, int stockQuantity, int maxOrderQuantity) { + this.brandId = brandId; + this.name = name; + this.description = description; + this.price = price; + this.stockQuantity = stockQuantity; + this.maxOrderQuantity = maxOrderQuantity; + this.likeCount = 0; + } + + public static Product create(Long brandId, String name, String description, Long price, int stockQuantity, int maxOrderQuantity) { + validateNotBlank(name, "상품명은 필수입니다."); + validatePositive(price, "가격은 0보다 커야 합니다."); + validateNotNegative(stockQuantity, "재고 수량은 0 이상이어야 합니다."); + validatePositive((long) maxOrderQuantity, "최대 주문 수량은 0보다 커야 합니다."); + return new Product(brandId, name, description, price, stockQuantity, maxOrderQuantity); + } + + public void update(String name, String description, Long price, int maxOrderQuantity) { + validateNotBlank(name, "상품명은 필수입니다."); + validatePositive(price, "가격은 0보다 커야 합니다."); + validatePositive((long) maxOrderQuantity, "최대 주문 수량은 0보다 커야 합니다."); + this.name = name; + this.description = description; + this.price = price; + this.maxOrderQuantity = maxOrderQuantity; + } + + public void updateStock(int quantity) { + validateNotNegative(quantity, "재고 수량은 0 이상이어야 합니다."); + this.stockQuantity = quantity; + } + + public void decreaseStock(int quantity) { + if (this.stockQuantity < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.stockQuantity -= quantity; + } + + public void increaseStock(int quantity) { + validatePositive((long) quantity, "증가 수량은 0보다 커야 합니다."); + this.stockQuantity += quantity; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public void validateOrderQuantity(int quantity) { + if (quantity > this.maxOrderQuantity) { + throw new CoreException(ErrorType.BAD_REQUEST, + "상품 '" + this.name + "'의 최대 주문 수량(" + this.maxOrderQuantity + ")을 초과했습니다."); + } + } + + private static void validateNotBlank(String value, String message) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validatePositive(Long value, String message) { + if (value == null || value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + private static void validateNotNegative(int value, String message) { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, message); + } + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Long getPrice() { + return price; + } + + public int getStockQuantity() { + return stockQuantity; + } + + public int getMaxOrderQuantity() { + return maxOrderQuantity; + } + + public int getLikeCount() { + return likeCount; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java new file mode 100644 index 000000000..4b6f97504 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductReader.java @@ -0,0 +1,13 @@ +package com.loopers.domain.product; + +import com.loopers.domain.PageResult; + +import java.util.List; +import java.util.Optional; + +public interface ProductReader { + Optional findById(Long id); + PageResult findAll(String keyword, Long brandId, ProductSortType sort, int page, int size); + List findAllByIds(List ids); + List findAllByBrandId(Long brandId); +} 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..3bd1bd330 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public interface ProductRepository { + Product save(Product product); +} 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..6b4a28990 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,80 @@ +package com.loopers.domain.product; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductReader productReader; + private final ProductRepository productRepository; + + @Transactional + public Product register(Long brandId, String name, String description, Long price, int stockQuantity, int maxOrderQuantity) { + Product product = Product.create(brandId, name, description, price, stockQuantity, maxOrderQuantity); + return productRepository.save(product); + } + + @Transactional(readOnly = true) + public Product getProduct(Long id) { + return productReader.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + @Transactional + public void update(Long id, String name, String description, Long price, int maxOrderQuantity) { + Product product = getProduct(id); + product.update(name, description, price, maxOrderQuantity); + } + + @Transactional + public void delete(Long id) { + Product product = getProduct(id); + product.delete(); + } + + @Transactional + public void updateStock(Long id, int quantity) { + Product product = getProduct(id); + product.updateStock(quantity); + } + + @Transactional + public void deleteAllByBrandId(Long brandId) { + List products = productReader.findAllByBrandId(brandId); + for (Product product : products) { + product.delete(); + } + } + + @Transactional + public int increaseLikeCount(Long id) { + Product product = getProduct(id); + product.increaseLikeCount(); + return product.getLikeCount(); + } + + @Transactional + public int decreaseLikeCount(Long id) { + Product product = getProduct(id); + product.decreaseLikeCount(); + return product.getLikeCount(); + } + + @Transactional(readOnly = true) + public List getProductsByIds(List ids) { + return productReader.findAllByIds(ids); + } + + @Transactional(readOnly = true) + public PageResult getProducts(String keyword, Long brandId, ProductSortType sort, int page, int size) { + return productReader.findAll(keyword, brandId, sort, page, size); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..8782fe7d8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,8 @@ +package com.loopers.domain.product; + +public enum ProductSortType { + LATEST, + PRICE_ASC, + PRICE_DESC, + LIKES_DESC +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressJpaRepository.java new file mode 100644 index 000000000..23c1a4ae9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.address; + +import com.loopers.domain.address.Address; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AddressJpaRepository extends JpaRepository { + + Optional
findByIdAndMemberIdAndDeletedAtIsNull(Long id, Long memberId); + + List
findAllByMemberIdAndDeletedAtIsNull(Long memberId); + + long countByMemberIdAndDeletedAtIsNull(Long memberId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressReaderImpl.java new file mode 100644 index 000000000..086a15a78 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressReaderImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.address; + +import com.loopers.domain.address.Address; +import com.loopers.domain.address.AddressReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class AddressReaderImpl implements AddressReader { + + private final AddressJpaRepository addressJpaRepository; + + @Override + public Optional
findByIdAndMemberId(Long id, Long memberId) { + return addressJpaRepository.findByIdAndMemberIdAndDeletedAtIsNull(id, memberId); + } + + @Override + public List
findAllByMemberId(Long memberId) { + return addressJpaRepository.findAllByMemberIdAndDeletedAtIsNull(memberId); + } + + @Override + public long countByMemberId(Long memberId) { + return addressJpaRepository.countByMemberIdAndDeletedAtIsNull(memberId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressRepositoryImpl.java new file mode 100644 index 000000000..7b852d434 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/address/AddressRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.address; + +import com.loopers.domain.address.Address; +import com.loopers.domain.address.AddressRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class AddressRepositoryImpl implements AddressRepository { + + private final AddressJpaRepository addressJpaRepository; + + @Override + public Address save(Address address) { + return addressJpaRepository.save(address); + } +} 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..abc4d618e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + boolean existsByIdAndDeletedAtIsNull(Long id); + boolean existsByNameAndDeletedAtIsNull(String name); + List findAllByIdInAndDeletedAtIsNull(List ids); + Page findByDeletedAtIsNull(Pageable pageable); + Page findByNameContainingAndDeletedAtIsNull(String keyword, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandReaderImpl.java new file mode 100644 index 000000000..e74cd278a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandReaderImpl.java @@ -0,0 +1,52 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandReader; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandReaderImpl implements BrandReader { + private final BrandJpaRepository brandJpaRepository; + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public boolean existsById(Long id) { + return brandJpaRepository.existsByIdAndDeletedAtIsNull(id); + } + + @Override + public boolean existsByName(String name) { + return brandJpaRepository.existsByNameAndDeletedAtIsNull(name); + } + + @Override + public List findAllByIds(List ids) { + return brandJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + + @Override + public PageResult findAll(String keyword, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page result; + if (keyword == null || keyword.isBlank()) { + result = brandJpaRepository.findByDeletedAtIsNull(pageable); + } else { + result = brandJpaRepository.findByNameContainingAndDeletedAtIsNull(keyword, pageable); + } + return new PageResult<>(result.getContent(), result.getTotalElements(), + result.getTotalPages(), result.getNumber(), result.getSize()); + } +} 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..a3fb2a66b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..2445a51f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeTargetType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + Optional findByMemberIdAndTargetTypeAndTargetId(Long memberId, LikeTargetType targetType, Long targetId); + Page findAllByMemberIdAndTargetType(Long memberId, LikeTargetType targetType, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeReaderImpl.java new file mode 100644 index 000000000..6f034df2c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeReaderImpl.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.PageResult; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeReader; +import com.loopers.domain.like.LikeTargetType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeReaderImpl implements LikeReader { + private final LikeJpaRepository likeJpaRepository; + + @Override + public Optional findByMemberIdAndTargetTypeAndTargetId(Long memberId, LikeTargetType targetType, Long targetId) { + return likeJpaRepository.findByMemberIdAndTargetTypeAndTargetId(memberId, targetType, targetId); + } + + @Override + public PageResult findAllByMemberIdAndTargetType(Long memberId, LikeTargetType targetType, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + Page result = likeJpaRepository.findAllByMemberIdAndTargetType(memberId, targetType, pageable); + return new PageResult<>(result.getContent(), result.getTotalElements(), + result.getTotalPages(), result.getNumber(), result.getSize()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..547033774 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } +} 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 index ba8b089f5..6d577eb74 100644 --- 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 @@ -7,5 +7,6 @@ public interface MemberJpaRepository extends JpaRepository { boolean existsByLoginId(String loginId); - Optional findByLoginId(String loginId); + Optional findByLoginIdAndDeletedAtIsNull(String loginId); + Optional findByIdAndDeletedAtIsNull(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java index 8dd8ce9c3..f7477cb6f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberReaderImpl.java @@ -1,16 +1,25 @@ package com.loopers.infrastructure.member; +import com.loopers.domain.PageResult; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberReader; +import com.loopers.domain.member.QMember; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @Component public class MemberReaderImpl implements MemberReader { + private final MemberJpaRepository memberJpaRepository; + private final JPAQueryFactory queryFactory; @Override public boolean existsByLoginId(String loginId) { @@ -19,6 +28,44 @@ public boolean existsByLoginId(String loginId) { @Override public Optional findByLoginId(String loginId) { - return memberJpaRepository.findByLoginId(loginId); + return memberJpaRepository.findByLoginIdAndDeletedAtIsNull(loginId); + } + + @Override + public Optional findById(Long id) { + return memberJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public PageResult findAll(String keyword, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + QMember member = QMember.member; + + BooleanBuilder where = new BooleanBuilder(); + where.and(member.deletedAt.isNull()); + + if (keyword != null && !keyword.isBlank()) { + where.and( + member.loginId.containsIgnoreCase(keyword) + .or(member.name.containsIgnoreCase(keyword)) + .or(member.email.containsIgnoreCase(keyword)) + ); + } + + List content = queryFactory.selectFrom(member) + .where(where) + .orderBy(member.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(member.count()) + .from(member) + .where(where) + .fetchOne(); + + long totalCount = total != null ? total : 0L; + int totalPages = (int) Math.ceil((double) totalCount / size); + return new PageResult<>(content, totalCount, totalPages, page, size); } } 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..e87ae2813 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderItemJpaRepository extends JpaRepository { + List findAllByOrderIdAndDeletedAtIsNull(Long orderId); + List findAllByOrderIdInAndDeletedAtIsNull(List orderIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemReaderImpl.java new file mode 100644 index 000000000..64b33d770 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemReaderImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderItemReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderItemReaderImpl implements OrderItemReader { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public List findAllByOrderId(Long orderId) { + return orderItemJpaRepository.findAllByOrderIdAndDeletedAtIsNull(orderId); + } + + @Override + public List findAllByOrderIds(List orderIds) { + return orderItemJpaRepository.findAllByOrderIdInAndDeletedAtIsNull(orderIds); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..1aeb54111 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public OrderItem save(OrderItem orderItem) { + return orderItemJpaRepository.save(orderItem); + } + + @Override + public List saveAll(List orderItems) { + return orderItemJpaRepository.saveAll(orderItems); + } +} 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..8da35dc08 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + Optional findByIdAndMemberIdAndDeletedAtIsNull(Long id, Long memberId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderReaderImpl.java new file mode 100644 index 000000000..202089781 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderReaderImpl.java @@ -0,0 +1,100 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderReader; +import com.loopers.domain.order.QOrder; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderReaderImpl implements OrderReader { + + private final OrderJpaRepository orderJpaRepository; + private final JPAQueryFactory queryFactory; + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByIdAndMemberId(Long id, Long memberId) { + return orderJpaRepository.findByIdAndMemberIdAndDeletedAtIsNull(id, memberId); + } + + @Override + public PageResult findAllByMemberId(Long memberId, LocalDate startAt, LocalDate endAt, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + QOrder order = QOrder.order; + + BooleanBuilder where = new BooleanBuilder(); + where.and(order.deletedAt.isNull()); + where.and(order.memberId.eq(memberId)); + + if (startAt != null) { + ZonedDateTime startDateTime = startAt.atStartOfDay(ZoneId.systemDefault()); + where.and(order.createdAt.goe(startDateTime)); + } + if (endAt != null) { + ZonedDateTime endDateTime = endAt.plusDays(1).atStartOfDay(ZoneId.systemDefault()); + where.and(order.createdAt.lt(endDateTime)); + } + + List content = queryFactory.selectFrom(order) + .where(where) + .orderBy(order.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(order.count()) + .from(order) + .where(where) + .fetchOne(); + + long totalCount = total != null ? total : 0L; + int totalPages = (int) Math.ceil((double) totalCount / size); + return new PageResult<>(content, totalCount, totalPages, page, size); + } + + @Override + public PageResult findAll(Long memberId, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + QOrder order = QOrder.order; + + BooleanBuilder where = new BooleanBuilder(); + where.and(order.deletedAt.isNull()); + + if (memberId != null) { + where.and(order.memberId.eq(memberId)); + } + + List content = queryFactory.selectFrom(order) + .where(where) + .orderBy(order.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(order.count()) + .from(order) + .where(where) + .fetchOne(); + + long totalCount = total != null ? total : 0L; + int totalPages = (int) Math.ceil((double) totalCount / size); + return new PageResult<>(content, totalCount, totalPages, page, size); + } +} 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..5ea7ca142 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } +} 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..f19b71a7f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByIdInAndDeletedAtIsNull(List ids); + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductReaderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductReaderImpl.java new file mode 100644 index 000000000..5dff2fec4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductReaderImpl.java @@ -0,0 +1,99 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.brand.QBrand; +import com.loopers.domain.PageResult; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductReader; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.QProduct; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductReaderImpl implements ProductReader { + + private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public PageResult findAll(String keyword, Long brandId, ProductSortType sort, int page, int size) { + Pageable pageable = PageRequest.of(page, size); + QProduct product = QProduct.product; + QBrand brand = QBrand.brand; + + BooleanBuilder where = new BooleanBuilder(); + where.and(product.deletedAt.isNull()); + + if (brandId != null) { + where.and(product.brandId.eq(brandId)); + } + + boolean needsJoin = keyword != null && !keyword.isBlank(); + if (needsJoin) { + where.and( + product.name.containsIgnoreCase(keyword) + .or(brand.name.containsIgnoreCase(keyword)) + ); + } + + OrderSpecifier orderSpecifier = resolveSort(sort, product); + + var query = queryFactory.selectFrom(product); + if (needsJoin) { + query.leftJoin(brand).on(brand.id.eq(product.brandId).and(brand.deletedAt.isNull())); + } + + List content = query + .where(where) + .orderBy(orderSpecifier) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + var countQuery = queryFactory.select(product.count()).from(product); + if (needsJoin) { + countQuery.leftJoin(brand).on(brand.id.eq(product.brandId).and(brand.deletedAt.isNull())); + } + Long total = countQuery.where(where).fetchOne(); + + long totalCount = total != null ? total : 0L; + int totalPages = (int) Math.ceil((double) totalCount / size); + return new PageResult<>(content, totalCount, totalPages, page, size); + } + + @Override + public List findAllByIds(List ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + } + + private OrderSpecifier resolveSort(ProductSortType sort, QProduct product) { + if (sort == null) { + return product.createdAt.desc(); + } + return switch (sort) { + case LATEST -> product.createdAt.desc(); + case PRICE_ASC -> product.price.asc(); + case PRICE_DESC -> product.price.desc(); + case LIKES_DESC -> product.likeCount.desc(); + }; + } +} 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..99165b0f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.java new file mode 100644 index 000000000..cf2cf6010 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Controller.java @@ -0,0 +1,84 @@ +package com.loopers.interfaces.api.address; + +import com.loopers.application.address.AddressFacade; +import com.loopers.application.address.AddressInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthUtils; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members/me/addresses") +public class AddressV1Controller { + + private final AddressFacade addressFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse register( + HttpServletRequest request, + @RequestBody AddressV1Dto.CreateAddressRequest body + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + AddressInfo info = addressFacade.register( + loginId, body.label(), body.recipientName(), body.recipientPhone(), + body.zipCode(), body.address1(), body.address2() + ); + return ApiResponse.success(AddressV1Dto.AddressResponse.from(info)); + } + + @GetMapping + public ApiResponse getAddresses(HttpServletRequest request) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + List infos = addressFacade.getAddresses(loginId); + return ApiResponse.success(AddressV1Dto.AddressListResponse.from(infos)); + } + + @PutMapping("/{addressId}") + public ApiResponse update( + HttpServletRequest request, + @PathVariable Long addressId, + @RequestBody AddressV1Dto.UpdateAddressRequest body + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + addressFacade.update( + loginId, addressId, body.label(), body.recipientName(), body.recipientPhone(), + body.zipCode(), body.address1(), body.address2() + ); + return ApiResponse.success(null); + } + + @DeleteMapping("/{addressId}") + public ApiResponse delete( + HttpServletRequest request, + @PathVariable Long addressId + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + addressFacade.delete(loginId, addressId); + return ApiResponse.success(null); + } + + @PatchMapping("/{addressId}/default") + public ApiResponse changeDefault( + HttpServletRequest request, + @PathVariable Long addressId + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + addressFacade.changeDefault(loginId, addressId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Dto.java new file mode 100644 index 000000000..b4fab53f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/address/AddressV1Dto.java @@ -0,0 +1,68 @@ +package com.loopers.interfaces.api.address; + +import com.loopers.application.address.AddressInfo; + +import java.util.List; + +public class AddressV1Dto { + + public record CreateAddressRequest( + String label, + String recipientName, + String recipientPhone, + String zipCode, + String address1, + String address2 + ) {} + + public record UpdateAddressRequest( + String label, + String recipientName, + String recipientPhone, + String zipCode, + String address1, + String address2 + ) {} + + public record AddressResponse( + AddressDto address + ) { + public static AddressResponse from(AddressInfo info) { + return new AddressResponse(AddressDto.from(info)); + } + } + + public record AddressListResponse( + List addresses + ) { + public static AddressListResponse from(List infos) { + return new AddressListResponse( + infos.stream().map(AddressDto::from).toList() + ); + } + } + + public record AddressDto( + Long id, + String label, + String recipientName, + String recipientPhone, + String zipCode, + String address1, + String address2, + boolean isDefault + ) { + public static AddressDto from(AddressInfo info) { + return new AddressDto( + info.id(), + info.label(), + info.recipientName(), + info.recipientPhone(), + info.zipCode(), + info.address1(), + info.address2(), + info.isDefault() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java new file mode 100644 index 000000000..ebea83f6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -0,0 +1,47 @@ +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.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminV1Controller { + + private final BrandFacade brandFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse register( + @RequestBody BrandV1Dto.RegisterRequest request + ) { + BrandInfo info = brandFacade.register(request.name(), request.description()); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } + + @PutMapping("/{brandId}") + public ApiResponse update( + @PathVariable Long brandId, + @RequestBody BrandV1Dto.UpdateRequest request + ) { + brandFacade.update(brandId, request.name(), request.description()); + return ApiResponse.success(null); + } + + @DeleteMapping("/{brandId}") + public ApiResponse delete(@PathVariable Long brandId) { + brandFacade.delete(brandId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..9e814b68e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.PagedInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller { + + private final BrandFacade brandFacade; + + @GetMapping + public ApiResponse getBrands( + @RequestParam(required = false) String keyword, + @PageableDefault(size = 20) Pageable pageable + ) { + PagedInfo brands = brandFacade.getBrands(keyword, pageable.getPageNumber(), pageable.getPageSize()); + return ApiResponse.success(BrandV1Dto.BrandListResponse.from(brands)); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandInfo info = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } +} 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..cd9260175 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.application.PagedInfo; + +public class BrandV1Dto { + + public record RegisterRequest( + String name, + String description + ) {} + + public record UpdateRequest( + String name, + String description + ) {} + + public record BrandResponse( + BrandDto brand + ) { + public record BrandDto( + Long id, + String name, + String description, + int likeCount + ) {} + + public static BrandResponse from(BrandInfo info) { + return new BrandResponse( + new BrandDto( + info.id(), + info.name(), + info.description(), + info.likeCount() + ) + ); + } + } + + public record BrandListResponse( + java.util.List brands, + PageInfo page + ) { + public record PageInfo( + int number, + int size, + long totalElements, + int totalPages + ) {} + + public static BrandListResponse from(PagedInfo result) { + var brandDtos = result.content().stream() + .map(info -> new BrandResponse.BrandDto( + info.id(), + info.name(), + info.description(), + info.likeCount() + )) + .toList(); + return new BrandListResponse( + brandDtos, + new PageInfo( + result.page(), + result.size(), + result.totalElements(), + result.totalPages() + ) + ); + } + } +} 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..0ee516f14 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,68 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.PagedInfo; +import com.loopers.application.like.BrandLikeInfo; +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeToggleInfo; +import com.loopers.application.like.ProductLikeInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthUtils; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller { + + private final LikeFacade likeFacade; + + @PostMapping("/api/v1/products/{productId}/likes") + public ApiResponse toggleProductLike( + HttpServletRequest request, + @PathVariable Long productId + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + LikeToggleInfo info = likeFacade.toggleProductLike(loginId, productId); + return ApiResponse.success(LikeV1Dto.ToggleResponse.from(info)); + } + + @PostMapping("/api/v1/brands/{brandId}/likes") + public ApiResponse toggleBrandLike( + HttpServletRequest request, + @PathVariable Long brandId + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + LikeToggleInfo info = likeFacade.toggleBrandLike(loginId, brandId); + return ApiResponse.success(LikeV1Dto.ToggleResponse.from(info)); + } + + @GetMapping("/api/v1/members/me/likes/products") + public ApiResponse getMyLikedProducts( + HttpServletRequest request, + @PageableDefault(size = 20) Pageable pageable + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + PagedInfo result = likeFacade.getMyLikedProducts( + loginId, pageable.getPageNumber(), pageable.getPageSize() + ); + return ApiResponse.success(LikeV1Dto.ProductLikeListResponse.from(result)); + } + + @GetMapping("/api/v1/members/me/likes/brands") + public ApiResponse getMyLikedBrands( + HttpServletRequest request, + @PageableDefault(size = 20) Pageable pageable + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + PagedInfo result = likeFacade.getMyLikedBrands( + loginId, pageable.getPageNumber(), pageable.getPageSize() + ); + return ApiResponse.success(LikeV1Dto.BrandLikeListResponse.from(result)); + } +} 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..fc394f69f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,110 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.PagedInfo; +import com.loopers.application.like.BrandLikeInfo; +import com.loopers.application.like.LikeToggleInfo; +import com.loopers.application.like.ProductLikeInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class LikeV1Dto { + + public record ToggleResponse(boolean liked, int likeCount) { + public static ToggleResponse from(LikeToggleInfo info) { + return new ToggleResponse(info.liked(), info.likeCount()); + } + } + + public record ProductLikeListResponse( + List products, + PageInfo page + ) { + public record ProductLikeDto( + ProductDto product, + ZonedDateTime likedAt + ) {} + + public record ProductDto( + Long id, + String name, + String description, + Long price, + int stockQuantity, + int maxOrderQuantity, + int likeCount, + BrandDto brand + ) {} + + public record BrandDto( + Long id, + String name, + String description, + int likeCount + ) {} + + public static ProductLikeListResponse from(PagedInfo result) { + var dtos = result.content().stream() + .map(info -> new ProductLikeDto( + new ProductDto( + info.product().id(), + info.product().name(), + info.product().description(), + info.product().price(), + info.product().stockQuantity(), + info.product().maxOrderQuantity(), + info.product().likeCount(), + new BrandDto( + info.product().brand().id(), + info.product().brand().name(), + info.product().brand().description(), + info.product().brand().likeCount() + ) + ), + info.likedAt() + )) + .toList(); + return new ProductLikeListResponse( + dtos, + new PageInfo(result.page(), result.size(), result.totalElements(), result.totalPages()) + ); + } + } + + public record BrandLikeListResponse( + List brands, + PageInfo page + ) { + public record BrandLikeDto( + BrandDto brand, + ZonedDateTime likedAt + ) {} + + public record BrandDto( + Long id, + String name, + String description, + int likeCount + ) {} + + public static BrandLikeListResponse from(PagedInfo result) { + var dtos = result.content().stream() + .map(info -> new BrandLikeDto( + new BrandDto( + info.brand().id(), + info.brand().name(), + info.brand().description(), + info.brand().likeCount() + ), + info.likedAt() + )) + .toList(); + return new BrandLikeListResponse( + dtos, + new PageInfo(result.page(), result.size(), result.totalElements(), result.totalPages()) + ); + } + } + + public record PageInfo(int number, int size, long totalElements, int totalPages) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberAdminV1Controller.java new file mode 100644 index 000000000..69e8d7212 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberAdminV1Controller.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.PagedInfo; +import com.loopers.application.member.AdminMemberInfo; +import com.loopers.application.member.MemberFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/members") +public class MemberAdminV1Controller { + + private final MemberFacade memberFacade; + + @GetMapping + public ApiResponse getMembers( + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PagedInfo result = memberFacade.getMembersForAdmin(keyword, page, size); + return ApiResponse.success(MemberAdminV1Dto.MemberListResponse.from(result)); + } + + @GetMapping("/{memberId}") + public ApiResponse getMember(@PathVariable Long memberId) { + AdminMemberInfo info = memberFacade.getMemberForAdmin(memberId); + return ApiResponse.success(MemberAdminV1Dto.MemberResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberAdminV1Dto.java new file mode 100644 index 000000000..77a06f96a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberAdminV1Dto.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.PagedInfo; +import com.loopers.application.member.AdminMemberInfo; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.List; + +public class MemberAdminV1Dto { + + public record MemberResponse(MemberDto member) { + public record MemberDto( + Long id, + String loginId, + String name, + LocalDate birthDate, + String gender, + String email, + String phone, + ZonedDateTime createdAt + ) {} + + public static MemberResponse from(AdminMemberInfo info) { + return new MemberResponse( + new MemberDto( + info.memberId(), + info.loginId(), + info.name(), + info.birthDate(), + info.gender(), + info.email(), + info.phone(), + info.createdAt() + ) + ); + } + } + + public record MemberListResponse(List members, PageInfo page) { + public record MemberDto( + Long id, + String loginId, + String name, + LocalDate birthDate, + String gender, + String email, + String phone, + ZonedDateTime createdAt + ) {} + + public record PageInfo(long totalElements, int totalPages, int page, int size) {} + + public static MemberListResponse from(PagedInfo result) { + List members = result.content().stream() + .map(info -> new MemberDto( + info.memberId(), + info.loginId(), + info.name(), + info.birthDate(), + info.gender(), + info.email(), + info.phone(), + info.createdAt() + )) + .toList(); + return new MemberListResponse( + members, + new PageInfo(result.totalElements(), result.totalPages(), result.page(), result.size()) + ); + } + } +} 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 index 4e811dfd1..fd4a7e948 100644 --- 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 @@ -2,13 +2,12 @@ import com.loopers.application.member.MemberFacade; import com.loopers.application.member.MemberInfo; -import com.loopers.domain.member.Member; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import com.loopers.support.auth.AuthUtils; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -34,15 +33,17 @@ public ApiResponse register( request.password(), request.name(), request.birthDate(), - request.email() + request.gender(), + request.email(), + request.phone() ); return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); } @GetMapping("/me") public ApiResponse getMe(HttpServletRequest request) { - Member authenticatedMember = getAuthenticatedMember(request); - MemberInfo info = memberFacade.getMe(authenticatedMember); + String loginId = AuthUtils.getAuthenticatedLoginId(request); + MemberInfo info = memberFacade.getMe(loginId); return ApiResponse.success(MemberV1Dto.MemberResponse.from(info)); } @@ -51,20 +52,32 @@ public ApiResponse changePassword( HttpServletRequest request, @RequestBody MemberV1Dto.ChangePasswordRequest passwordRequest ) { - Member authenticatedMember = getAuthenticatedMember(request); + String loginId = AuthUtils.getAuthenticatedLoginId(request); memberFacade.changePassword( - authenticatedMember, + loginId, passwordRequest.currentPassword(), passwordRequest.newPassword() ); return ApiResponse.success(null); } - private Member getAuthenticatedMember(HttpServletRequest request) { - Object attribute = request.getAttribute("authenticatedMember"); - if (!(attribute instanceof Member member)) { - throw new CoreException(ErrorType.UNAUTHORIZED, "인증된 회원 정보가 없습니다."); - } - return member; + @PatchMapping("/me") + public ApiResponse updatePhone( + HttpServletRequest request, + @RequestBody MemberV1Dto.UpdatePhoneRequest phoneRequest + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + memberFacade.updatePhone(loginId, phoneRequest.phone()); + return ApiResponse.success(null); + } + + @DeleteMapping("/me") + public ApiResponse withdraw( + HttpServletRequest request, + @RequestBody MemberV1Dto.WithdrawRequest withdrawRequest + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + memberFacade.withdraw(loginId, withdrawRequest.password()); + 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 index 9536c4d95..0c714a8d8 100644 --- 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 @@ -11,7 +11,9 @@ public record RegisterRequest( String password, String name, LocalDate birthDate, - String email + String gender, + String email, + String phone ) {} public record ChangePasswordRequest( @@ -19,6 +21,14 @@ public record ChangePasswordRequest( String newPassword ) {} + public record UpdatePhoneRequest( + String phone + ) {} + + public record WithdrawRequest( + String password + ) {} + public record MemberResponse( MemberDto member ) { @@ -26,7 +36,9 @@ public record MemberDto( String loginId, String name, LocalDate birthDate, - String email + String gender, + String email, + String phone ) {} public static MemberResponse from(MemberInfo info) { @@ -35,7 +47,9 @@ public static MemberResponse from(MemberInfo info) { info.loginId(), info.name(), info.birthDate(), - info.email() + info.gender(), + info.email(), + info.phone() ) ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java new file mode 100644 index 000000000..ee8c92d0a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -0,0 +1,33 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.PagedInfo; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderSummaryInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminV1Controller { + + private final OrderFacade orderFacade; + + @GetMapping + public ApiResponse getOrders( + @RequestParam(required = false) Long memberId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PagedInfo result = orderFacade.getOrdersForAdmin(memberId, page, size); + return ApiResponse.success(OrderV1Dto.OrderListResponse.from(result)); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrder(@PathVariable Long orderId) { + OrderInfo info = orderFacade.getOrderForAdmin(orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } +} 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..8b18ee15c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,83 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.PagedInfo; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderSummaryInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.auth.AuthUtils; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class OrderV1Controller { + + private final OrderFacade orderFacade; + + @PostMapping("/api/v1/orders") + public ApiResponse createOrder( + HttpServletRequest request, + @RequestBody OrderV1Dto.CreateOrderRequest body + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + List itemRequests = body.items().stream() + .map(item -> new OrderFacade.OrderItemRequest(item.productId(), item.quantity())) + .toList(); + OrderInfo info = orderFacade.createOrder(loginId, body.addressId(), itemRequests); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } + + @GetMapping("/api/v1/orders") + public ApiResponse getMyOrders( + HttpServletRequest request, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startAt, + @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endAt, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + PagedInfo result = orderFacade.getMyOrders(loginId, startAt, endAt, page, size); + return ApiResponse.success(OrderV1Dto.OrderListResponse.from(result)); + } + + @GetMapping("/api/v1/orders/{orderId}") + public ApiResponse getOrder( + HttpServletRequest request, + @PathVariable Long orderId + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + OrderInfo info = orderFacade.getOrder(loginId, orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } + + @PostMapping("/api/v1/orders/{orderId}/cancel") + public ApiResponse cancelOrder( + HttpServletRequest request, + @PathVariable Long orderId + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + orderFacade.cancelOrder(loginId, orderId); + return ApiResponse.success(); + } + + @PutMapping("/api/v1/orders/{orderId}/shipping-address") + public ApiResponse updateShippingAddress( + HttpServletRequest request, + @PathVariable Long orderId, + @RequestBody OrderV1Dto.UpdateShippingAddressRequest body + ) { + String loginId = AuthUtils.getAuthenticatedLoginId(request); + OrderInfo info = orderFacade.updateShippingAddress( + loginId, orderId, + body.recipientName(), body.recipientPhone(), + body.zipCode(), body.address1(), body.address2() + ); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } +} 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..28abfab75 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,101 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.PagedInfo; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemInfo; +import com.loopers.application.order.OrderSummaryInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateOrderRequest( + Long addressId, + List items + ) { + public record OrderItemRequest(Long productId, int quantity) {} + } + + public record UpdateShippingAddressRequest( + String recipientName, + String recipientPhone, + String zipCode, + String address1, + String address2 + ) {} + + public record OrderResponse(OrderDto order) { + public static OrderResponse from(OrderInfo info) { + List items = info.items().stream() + .map(OrderItemDto::from) + .toList(); + return new OrderResponse(new OrderDto( + info.id(), info.memberId(), + info.recipientName(), info.recipientPhone(), + info.zipCode(), info.address1(), info.address2(), + info.totalAmount(), info.status(), items, info.createdAt() + )); + } + } + + public record OrderDto( + Long id, + Long memberId, + String recipientName, + String recipientPhone, + String zipCode, + String address1, + String address2, + Long totalAmount, + String status, + List items, + ZonedDateTime createdAt + ) {} + + public record OrderItemDto( + Long id, + Long productId, + String productName, + Long productPrice, + int quantity, + Long subtotal + ) { + public static OrderItemDto from(OrderItemInfo info) { + return new OrderItemDto( + info.id(), info.productId(), info.productName(), + info.productPrice(), info.quantity(), info.subtotal() + ); + } + } + + public record OrderListResponse(List orders, PageInfo page) { + public static OrderListResponse from(PagedInfo result) { + var dtos = result.content().stream() + .map(OrderSummaryDto::from) + .toList(); + return new OrderListResponse( + dtos, + new PageInfo(result.page(), result.size(), result.totalElements(), result.totalPages()) + ); + } + } + + public record OrderSummaryDto( + Long id, + Long totalAmount, + String status, + int itemCount, + String representativeProductName, + ZonedDateTime createdAt + ) { + public static OrderSummaryDto from(OrderSummaryInfo info) { + return new OrderSummaryDto( + info.id(), info.totalAmount(), info.status(), + info.itemCount(), info.representativeProductName(), info.createdAt() + ); + } + } + + public record PageInfo(int number, int size, long totalElements, int totalPages) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java new file mode 100644 index 000000000..48c1e827a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class ProductAdminV1Controller { + + private final ProductFacade productFacade; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse register( + @RequestBody ProductV1Dto.RegisterRequest request + ) { + ProductInfo info = productFacade.register( + request.brandId(), request.name(), request.description(), + request.price(), request.stockQuantity(), request.maxOrderQuantity() + ); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(info)); + } + + @PutMapping("/{productId}") + public ApiResponse update( + @PathVariable Long productId, + @RequestBody ProductV1Dto.UpdateRequest request + ) { + productFacade.update(productId, request.name(), request.description(), + request.price(), request.maxOrderQuantity()); + return ApiResponse.success(null); + } + + @DeleteMapping("/{productId}") + public ApiResponse delete(@PathVariable Long productId) { + productFacade.delete(productId); + return ApiResponse.success(null); + } + + @PatchMapping("/{productId}/stock") + public ApiResponse updateStock( + @PathVariable Long productId, + @RequestBody ProductV1Dto.UpdateStockRequest request + ) { + productFacade.updateStock(productId, request.quantity()); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..fa7395a09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.PagedInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + private final ProductFacade productFacade; + + @GetMapping + public ApiResponse getProducts( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Long brandId, + @RequestParam(required = false, defaultValue = "LATEST") String sort, + @PageableDefault(size = 20) Pageable pageable + ) { + PagedInfo products = productFacade.getProducts( + keyword, brandId, sort, pageable.getPageNumber(), pageable.getPageSize() + ); + return ApiResponse.success(ProductV1Dto.ProductListResponse.from(products)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductInfo info = productFacade.getProduct(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(info)); + } +} 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..8d2b22bde --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,101 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import com.loopers.application.PagedInfo; + +import java.util.List; + +public class ProductV1Dto { + + public record RegisterRequest( + Long brandId, + String name, + String description, + Long price, + int stockQuantity, + int maxOrderQuantity + ) {} + + public record UpdateRequest( + String name, + String description, + Long price, + int maxOrderQuantity + ) {} + + public record UpdateStockRequest( + int quantity + ) {} + + public record ProductResponse(ProductDto product) { + + public record ProductDto( + Long id, + String name, + String description, + Long price, + int stockQuantity, + int maxOrderQuantity, + int likeCount, + BrandDto brand + ) {} + + public record BrandDto( + Long id, + String name, + String description, + int likeCount + ) {} + + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + new ProductDto( + info.id(), + info.name(), + info.description(), + info.price(), + info.stockQuantity(), + info.maxOrderQuantity(), + info.likeCount(), + new BrandDto( + info.brand().id(), + info.brand().name(), + info.brand().description(), + info.brand().likeCount() + ) + ) + ); + } + } + + public record ProductListResponse( + List products, + PageInfo page + ) { + public record PageInfo(int number, int size, long totalElements, int totalPages) {} + + public static ProductListResponse from(PagedInfo result) { + var productDtos = result.content().stream() + .map(info -> new ProductResponse.ProductDto( + info.id(), + info.name(), + info.description(), + info.price(), + info.stockQuantity(), + info.maxOrderQuantity(), + info.likeCount(), + new ProductResponse.BrandDto( + info.brand().id(), + info.brand().name(), + info.brand().description(), + info.brand().likeCount() + ) + )) + .toList(); + return new ProductListResponse( + productDtos, + new PageInfo(result.page(), result.size(), result.totalElements(), result.totalPages()) + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/security/AdminAuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/security/AdminAuthFilter.java new file mode 100644 index 000000000..511a3a4d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/security/AdminAuthFilter.java @@ -0,0 +1,56 @@ +package com.loopers.interfaces.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class AdminAuthFilter extends OncePerRequestFilter { + + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (!requiresAuthentication(request)) { + filterChain.doFilter(request, response); + return; + } + + String ldapValue = request.getHeader(HEADER_LDAP); + + if (!ADMIN_LDAP_VALUE.equals(ldapValue)) { + sendUnauthorizedResponse(response); + return; + } + + filterChain.doFilter(request, response); + } + + private void sendUnauthorizedResponse(HttpServletResponse response) throws IOException { + ErrorType errorType = ErrorType.UNAUTHORIZED; + response.setStatus(errorType.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + ApiResponse body = ApiResponse.fail(errorType.getCode(), errorType.getMessage()); + objectMapper.writeValue(response.getWriter(), body); + } + + private boolean requiresAuthentication(HttpServletRequest request) { + return request.getRequestURI().startsWith("/api-admin/"); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/security/MemberAuthFilter.java similarity index 74% rename from apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/security/MemberAuthFilter.java index 5d029739f..9531ffca2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/auth/MemberAuthFilter.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/security/MemberAuthFilter.java @@ -1,9 +1,7 @@ -package com.loopers.support.auth; +package com.loopers.interfaces.security; import com.fasterxml.jackson.databind.ObjectMapper; -import com.loopers.domain.member.Member; -import com.loopers.domain.member.MemberReader; -import com.loopers.domain.member.PasswordEncoder; +import com.loopers.application.member.MemberFacade; import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.error.ErrorType; import jakarta.servlet.FilterChain; @@ -24,8 +22,7 @@ public class MemberAuthFilter extends OncePerRequestFilter { private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; - private final MemberReader memberReader; - private final PasswordEncoder passwordEncoder; + private final MemberFacade memberFacade; private final ObjectMapper objectMapper; @Override @@ -47,17 +44,15 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } // 회원 조회 및 비밀번호 검증 - Member member = memberReader.findByLoginId(loginId) - .filter(m -> passwordEncoder.matches(loginPw, m.getPassword())) - .orElse(null); + boolean authenticated = memberFacade.authenticate(loginId, loginPw); - if (member == null) { + if (!authenticated) { sendUnauthorizedResponse(response); return; } - // 인증 성공 - 회원 정보를 request에 저장 - request.setAttribute("authenticatedMember", member); + // 인증 성공 - loginId를 request에 저장 + request.setAttribute("authenticatedLoginId", loginId); filterChain.doFilter(request, response); } @@ -80,6 +75,21 @@ private boolean requiresAuthentication(HttpServletRequest request) { } // /api/v1/members/** 경로는 인증 필요 - return path.startsWith("/api/v1/members/"); + if (path.startsWith("/api/v1/members/")) { + return true; + } + + // POST /api/v1/products/{id}/likes, POST /api/v1/brands/{id}/likes 인증 필요 + if ("POST".equals(method) && path.endsWith("/likes") + && (path.startsWith("/api/v1/products/") || path.startsWith("/api/v1/brands/"))) { + return true; + } + + // /api/v1/orders/** 경로는 인증 필요 + if (path.startsWith("/api/v1/orders")) { + return true; + } + + return false; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUtils.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUtils.java new file mode 100644 index 000000000..cb2a06cce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthUtils.java @@ -0,0 +1,18 @@ +package com.loopers.support.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; + +public class AuthUtils { + + private AuthUtils() {} + + public static String getAuthenticatedLoginId(HttpServletRequest request) { + Object attribute = request.getAttribute("authenticatedLoginId"); + if (!(attribute instanceof String loginId)) { + throw new CoreException(ErrorType.UNAUTHORIZED, "인증된 회원 정보가 없습니다."); + } + return loginId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java index 57ea9a043..ce2f5eb7b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/SecurityConfig.java @@ -1,6 +1,7 @@ package com.loopers.support.config; -import com.loopers.support.auth.MemberAuthFilter; +import com.loopers.interfaces.security.AdminAuthFilter; +import com.loopers.interfaces.security.MemberAuthFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,12 +16,14 @@ public class SecurityConfig { private final MemberAuthFilter memberAuthFilter; + private final AdminAuthFilter adminAuthFilter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .addFilterBefore(adminAuthFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(memberAuthFilter, UsernamePasswordAuthenticationFilter.class) .build(); } diff --git a/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java new file mode 100644 index 000000000..7849fa740 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java @@ -0,0 +1,68 @@ +package com.loopers; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; + +@AnalyzeClasses(packages = "com.loopers", importOptions = ImportOption.DoNotIncludeTests.class) +class ArchitectureTest { + + // — 1. 계층형 아키텍처 의존성 검증 — + // Interfaces → Application → Domain ← Infrastructure + // support 패키지는 cross-cutting concern (에러, 인증, 설정)이므로 레이어 검증에서 제외 + @ArchTest + static final ArchRule layered_architecture_is_respected = layeredArchitecture() + .consideringOnlyDependenciesInAnyPackage("com.loopers..") + .layer("Interfaces").definedBy("..interfaces..") + .layer("Application").definedBy("..application..") + .layer("Domain").definedBy("..domain..") + .layer("Infrastructure").definedBy("..infrastructure..") + .optionalLayer("Support").definedBy("..support..", "..config..") + + .whereLayer("Interfaces").mayOnlyBeAccessedByLayers("Support") + .whereLayer("Application").mayOnlyBeAccessedByLayers("Interfaces") + .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure", "Support") + .whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer(); + + // — 2. 도메인 간 순환 참조 방지 — + // 각 도메인 (member, product, brand, like, order 등)이 서로 순환 의존하지 않아야 함 + @ArchTest + static final ArchRule no_cycles_between_domains = slices() + .matching("com.loopers.domain.(*)..") + .should().beFreeOfCycles(); + + // — 3. Application 계층은 인프라 기술에 직접 의존하지 않음 — + @ArchTest + static final ArchRule application_should_not_use_jpa_annotations = noClasses() + .that().resideInAPackage("..application..") + .should().dependOnClassesThat().resideInAnyPackage( + "jakarta.persistence..", + "org.springframework.data.." + ); + + // — 4. Domain Repository 인터페이스는 순수 자바 (JPA/Spring Data 비의존) — + // DIP: domainRepository는 특정 데이터베이스 기술에 종속되지 않은 순수한 자바 인터페이스여야 함 + @ArchTest + static final ArchRule domain_repository_should_be_pure_java = noClasses() + .that().resideInAPackage("..domain..") + .and().haveSimpleNameEndingWith("Repository") + .should().dependOnClassesThat().resideInAnyPackage( + "jakarta.persistence..", + "org.springframework.data.." + ); + + // — 5. Domain Entity는 무분별한 setter를 노출하지 않음 — + // 의미 있는 메서드명(예: changeShippingInfo())을 통해 상태를 변경하도록 제어 + @ArchTest + static final ArchRule domain_should_not_expose_setters = methods() + .that().haveNameMatching("set[A-Z].*") + .and().areDeclaredInClassesThat().resideInAPackage("..domain..") + .should().notBePublic() + .allowEmptyShould(true); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.java new file mode 100644 index 000000000..db0da13f5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressServiceTest.java @@ -0,0 +1,325 @@ +package com.loopers.domain.address; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +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; + +class AddressServiceTest { + + private AddressService addressService; + private FakeAddressReader fakeAddressReader; + private FakeAddressRepository fakeAddressRepository; + + @BeforeEach + void setUp() { + fakeAddressReader = new FakeAddressReader(); + fakeAddressRepository = new FakeAddressRepository(fakeAddressReader); + addressService = new AddressService(fakeAddressReader, fakeAddressRepository); + } + + @DisplayName("배송지를 등록할 때, ") + @Nested + class Register { + + @DisplayName("첫 등록이면, 자동으로 기본 배송지가 된다.") + @Test + void setsDefault_whenFirstAddress() { + // Act + Address address = addressService.register(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", "101동 202호"); + + // Assert + assertAll( + () -> assertThat(address.getLabel()).isEqualTo("집"), + () -> assertThat(address.getIsDefault()).isTrue() + ); + } + + @DisplayName("이미 배송지가 있으면, 기본 배송지가 아닌 상태로 등록된다.") + @Test + void doesNotSetDefault_whenOtherAddressesExist() { + // Arrange + addressService.register(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", "101동 202호"); + + // Act + Address address = addressService.register(1L, "회사", "홍길동", "010-1234-5678", + "54321", "서울시 서초구", null); + + // Assert + assertThat(address.getIsDefault()).isFalse(); + } + + @DisplayName("최대 10개를 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenExceedsMaxCount() { + // Arrange + for (int i = 0; i < 10; i++) { + addressService.register(1L, "배송지" + i, "홍길동", "010-1234-5678", + "12345", "주소" + i, null); + } + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + addressService.register(1L, "배송지11", "홍길동", "010-1234-5678", + "12345", "주소11", null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("배송지를 조회할 때, ") + @Nested + class GetAddress { + + @DisplayName("존재하는 배송지이면, 배송지를 반환한다.") + @Test + void returnsAddress_whenExists() { + // Arrange + Address saved = addressService.register(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null); + + // Act + Address found = addressService.getAddress(1L, 1L); + + // Assert + assertThat(found.getLabel()).isEqualTo("집"); + } + + @DisplayName("존재하지 않는 배송지이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + addressService.getAddress(999L, 1L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("타인의 배송지이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenOtherMember() { + // Arrange + addressService.register(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + addressService.getAddress(1L, 2L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("배송지 목록을 조회할 때, ") + @Nested + class GetAddresses { + + @DisplayName("본인의 배송지만 반환한다.") + @Test + void returnsOwnAddresses() { + // Arrange + addressService.register(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null); + addressService.register(1L, "회사", "홍길동", "010-1234-5678", + "54321", "서울시 서초구", null); + addressService.register(2L, "타인집", "김철수", "010-9999-9999", + "99999", "부산시", null); + + // Act + List
addresses = addressService.getAddresses(1L); + + // Assert + assertThat(addresses).hasSize(2); + } + } + + @DisplayName("배송지를 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 정보로 수정하면, 정상적으로 수정된다.") + @Test + void updatesAddress_whenValid() { + // Arrange + addressService.register(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null); + + // Act + addressService.update(1L, 1L, "회사", "김철수", "010-9876-5432", + "54321", "서울시 서초구", "301동"); + + // Assert + Address updated = addressService.getAddress(1L, 1L); + assertAll( + () -> assertThat(updated.getLabel()).isEqualTo("회사"), + () -> assertThat(updated.getRecipientName()).isEqualTo("김철수"), + () -> assertThat(updated.getAddress1()).isEqualTo("서울시 서초구") + ); + } + + @DisplayName("존재하지 않는 배송지이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + addressService.update(999L, 1L, "회사", "김철수", "010-9876-5432", + "54321", "서울시 서초구", null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("배송지를 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("비기본 배송지이면, soft delete 처리된다.") + @Test + void deletesAddress_whenNotDefault() { + // Arrange + addressService.register(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null); + addressService.register(1L, "회사", "홍길동", "010-1234-5678", + "54321", "서울시 서초구", null); + + // Act + addressService.delete(2L, 1L); + + // Assert + CoreException exception = assertThrows(CoreException.class, () -> + addressService.getAddress(2L, 1L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("기본 배송지이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenDefaultAddress() { + // Arrange + addressService.register(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null); // 첫 등록 → 기본 배송지 + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + addressService.delete(1L, 1L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 배송지이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + addressService.delete(999L, 1L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("기본 배송지를 설정할 때, ") + @Nested + class ChangeDefault { + + @DisplayName("다른 배송지를 기본으로 설정하면, 기존 기본이 해제되고 새 기본이 설정된다.") + @Test + void changesDefault_whenSettingNewDefault() { + // Arrange + addressService.register(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null); // id=1, 기본 + addressService.register(1L, "회사", "홍길동", "010-1234-5678", + "54321", "서울시 서초구", null); // id=2, 비기본 + + // Act + addressService.changeDefault(2L, 1L); + + // Assert + Address oldDefault = addressService.getAddress(1L, 1L); + Address newDefault = addressService.getAddress(2L, 1L); + assertAll( + () -> assertThat(oldDefault.getIsDefault()).isFalse(), + () -> assertThat(newDefault.getIsDefault()).isTrue() + ); + } + + @DisplayName("존재하지 않는 배송지이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + addressService.changeDefault(999L, 1L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + // Fake 구현체 + static class FakeAddressReader implements AddressReader { + private final Map addresses = new HashMap<>(); + private final Map> addressesByMemberId = new HashMap<>(); + + void addAddress(Long id, Long memberId, Address address) { + addresses.put(id, address); + addressesByMemberId.computeIfAbsent(memberId, k -> new ArrayList<>()).add(address); + } + + void removeAddress(Long id, Long memberId) { + addresses.remove(id); + List
list = addressesByMemberId.get(memberId); + if (list != null) { + list.removeIf(a -> a == addresses.get(id)); + } + } + + @Override + public Optional
findByIdAndMemberId(Long id, Long memberId) { + Address address = addresses.get(id); + if (address != null && address.getMemberId().equals(memberId) && address.getDeletedAt() == null) { + return Optional.of(address); + } + return Optional.empty(); + } + + @Override + public List
findAllByMemberId(Long memberId) { + return addressesByMemberId.getOrDefault(memberId, List.of()).stream() + .filter(a -> a.getDeletedAt() == null) + .toList(); + } + + @Override + public long countByMemberId(Long memberId) { + return findAllByMemberId(memberId).size(); + } + } + + static class FakeAddressRepository implements AddressRepository { + private final FakeAddressReader fakeAddressReader; + private long idSequence = 1L; + + FakeAddressRepository(FakeAddressReader fakeAddressReader) { + this.fakeAddressReader = fakeAddressReader; + } + + @Override + public Address save(Address address) { + long id = idSequence++; + fakeAddressReader.addAddress(id, address.getMemberId(), address); + return address; + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.java new file mode 100644 index 000000000..8814daf62 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/address/AddressTest.java @@ -0,0 +1,227 @@ +package com.loopers.domain.address; + +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 AddressTest { + + @DisplayName("배송지를 생성할 때, ") + @Nested + class Create { + + @DisplayName("모든 필드가 유효하면, 정상적으로 생성된다.") + @Test + void createsAddress_whenAllFieldsAreValid() { + // Arrange & Act + Address address = Address.create(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", "101동 202호", true); + + // Assert + assertAll( + () -> assertThat(address.getMemberId()).isEqualTo(1L), + () -> assertThat(address.getLabel()).isEqualTo("집"), + () -> assertThat(address.getRecipientName()).isEqualTo("홍길동"), + () -> assertThat(address.getRecipientPhone()).isEqualTo("010-1234-5678"), + () -> assertThat(address.getZipCode()).isEqualTo("12345"), + () -> assertThat(address.getAddress1()).isEqualTo("서울시 강남구"), + () -> assertThat(address.getAddress2()).isEqualTo("101동 202호"), + () -> assertThat(address.getIsDefault()).isTrue() + ); + } + + @DisplayName("상세주소가 null이어도, 정상적으로 생성된다.") + @Test + void createsAddress_whenAddress2IsNull() { + // Arrange & Act + Address address = Address.create(1L, "회사", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null, false); + + // Assert + assertThat(address.getAddress2()).isNull(); + } + + @DisplayName("배송지명이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLabelIsBlank() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Address.create(1L, " ", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null, false) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수령인 이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenRecipientNameIsNull() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Address.create(1L, "집", null, "010-1234-5678", + "12345", "서울시 강남구", null, false) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수령인 전화번호가 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenRecipientPhoneIsBlank() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Address.create(1L, "집", "홍길동", " ", + "12345", "서울시 강남구", null, false) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("우편번호가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenZipCodeIsNull() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Address.create(1L, "집", "홍길동", "010-1234-5678", + null, "서울시 강남구", null, false) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("기본주소가 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAddress1IsBlank() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Address.create(1L, "집", "홍길동", "010-1234-5678", + "12345", " ", null, false) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("배송지를 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 정보로 수정하면, 정상적으로 수정된다.") + @Test + void updatesAddress_whenFieldsAreValid() { + // Arrange + Address address = Address.create(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", "101동 202호", true); + + // Act + address.update("회사", "김철수", "010-9876-5432", + "54321", "서울시 서초구", "301동 402호"); + + // Assert + assertAll( + () -> assertThat(address.getLabel()).isEqualTo("회사"), + () -> assertThat(address.getRecipientName()).isEqualTo("김철수"), + () -> assertThat(address.getRecipientPhone()).isEqualTo("010-9876-5432"), + () -> assertThat(address.getZipCode()).isEqualTo("54321"), + () -> assertThat(address.getAddress1()).isEqualTo("서울시 서초구"), + () -> assertThat(address.getAddress2()).isEqualTo("301동 402호"), + () -> assertThat(address.getIsDefault()).isTrue() // isDefault는 변경되지 않음 + ); + } + + @DisplayName("배송지명이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenLabelIsNull() { + // Arrange + Address address = Address.create(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null, false); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + address.update(null, "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("기본주소가 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAddress1IsBlank() { + // Arrange + Address address = Address.create(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null, false); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + address.update("집", "홍길동", "010-1234-5678", + "12345", " ", null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("배송지를 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("비기본 배송지이면, 정상적으로 삭제된다.") + @Test + void deletesAddress_whenNotDefault() { + // Arrange + Address address = Address.create(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null, false); + + // Act + address.delete(); + + // Assert + assertThat(address.getDeletedAt()).isNotNull(); + } + + @DisplayName("기본 배송지이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenDefaultAddress() { + // Arrange + Address address = Address.create(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null, true); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, address::delete); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("기본 배송지를 설정할 때, ") + @Nested + class ChangeDefault { + + @DisplayName("true로 설정하면, 기본 배송지가 된다.") + @Test + void setsDefault_whenTrue() { + // Arrange + Address address = Address.create(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null, false); + + // Act + address.changeDefault(true); + + // Assert + assertThat(address.getIsDefault()).isTrue(); + } + + @DisplayName("false로 설정하면, 기본 배송지가 해제된다.") + @Test + void unsetsDefault_whenFalse() { + // Arrange + Address address = Address.create(1L, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null, true); + + // Act + address.changeDefault(false); + + // Assert + assertThat(address.getIsDefault()).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java new file mode 100644 index 000000000..7e6cb0ae9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,233 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import com.loopers.domain.PageResult; + +import java.util.HashMap; +import java.util.List; +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; + +class BrandServiceTest { + + private BrandService brandService; + private FakeBrandReader fakeBrandReader; + private FakeBrandRepository fakeBrandRepository; + + @BeforeEach + void setUp() { + fakeBrandReader = new FakeBrandReader(); + fakeBrandRepository = new FakeBrandRepository(); + brandService = new BrandService(fakeBrandReader, fakeBrandRepository); + } + + @DisplayName("브랜드를 등록할 때, ") + @Nested + class Register { + + @DisplayName("유효한 정보로 등록하면, 브랜드가 저장된다.") + @Test + void savesBrand_whenFieldsAreValid() { + // Arrange & Act + Brand brand = brandService.register("Nike", "Just Do It"); + + // Assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("Nike"), + () -> assertThat(brand.getDescription()).isEqualTo("Just Do It"), + () -> assertThat(brand.getLikeCount()).isZero() + ); + } + + @DisplayName("이미 존재하는 브랜드명으로 등록하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameAlreadyExists() { + // Arrange + fakeBrandReader.addExistingName("Nike"); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.register("Nike", "Just Do It"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("브랜드를 조회할 때, ") + @Nested + class GetBrand { + + @DisplayName("존재하는 브랜드를 조회하면, 브랜드를 반환한다.") + @Test + void returnsBrand_whenExists() { + // Arrange + Brand saved = fakeBrandRepository.save(Brand.create("Nike", "Just Do It")); + fakeBrandReader.addBrand(1L, saved); + + // Act + Brand found = brandService.getBrand(1L); + + // Assert + assertThat(found.getName()).isEqualTo("Nike"); + } + + @DisplayName("존재하지 않는 브랜드를 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.getBrand(999L); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드를 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 정보로 수정하면, 브랜드가 수정된다.") + @Test + void updatesBrand_whenFieldsAreValid() { + // Arrange + Brand brand = Brand.create("Nike", "Just Do It"); + fakeBrandReader.addBrand(1L, brand); + + // Act + brandService.update(1L, "Adidas", "Impossible Is Nothing"); + + // Assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("Adidas"), + () -> assertThat(brand.getDescription()).isEqualTo("Impossible Is Nothing") + ); + } + + @DisplayName("존재하지 않는 브랜드를 수정하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.update(999L, "Adidas", "설명"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드를 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드를 삭제하면, soft delete 처리된다.") + @Test + void deletesBrand_whenExists() { + // Arrange + Brand brand = Brand.create("Nike", "Just Do It"); + fakeBrandReader.addBrand(1L, brand); + + // Act + brandService.delete(1L); + + // Assert + assertThat(brand.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 브랜드를 삭제하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.delete(999L); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 목록을 조회할 때, ") + @Nested + class GetBrands { + + @DisplayName("키워드 없이 조회하면, 전체 브랜드를 반환한다.") + @Test + void returnsAllBrands_whenNoKeyword() { + // Arrange + Brand nike = Brand.create("Nike", "Just Do It"); + Brand adidas = Brand.create("Adidas", "Impossible Is Nothing"); + fakeBrandReader.addBrands(List.of(nike, adidas)); + + // Act + PageResult result = brandService.getBrands(null, 0, 20); + + // Assert + assertThat(result.content()).hasSize(2); + } + } + + // Fake 구현체 + static class FakeBrandReader implements BrandReader { + private final Map brands = new HashMap<>(); + private final Map existingNames = new HashMap<>(); + private List allBrands = List.of(); + + void addBrand(Long id, Brand brand) { + brands.put(id, brand); + } + + void addExistingName(String name) { + existingNames.put(name, true); + } + + void addBrands(List brands) { + this.allBrands = brands; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(brands.get(id)); + } + + @Override + public boolean existsById(Long id) { + return brands.containsKey(id); + } + + @Override + public boolean existsByName(String name) { + return existingNames.containsKey(name); + } + + @Override + public List findAllByIds(List ids) { + return ids.stream() + .map(brands::get) + .filter(java.util.Objects::nonNull) + .toList(); + } + + @Override + public PageResult findAll(String keyword, int page, int size) { + return new PageResult<>(allBrands, allBrands.size(), 1, page, size); + } + } + + static class FakeBrandRepository implements BrandRepository { + private final Map brands = new HashMap<>(); + private long idSequence = 1L; + + @Override + public Brand save(Brand brand) { + brands.put(idSequence++, brand); + return brand; + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..c20fdc2f2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,180 @@ +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; + +class BrandTest { + + @DisplayName("브랜드를 생성할 때, ") + @Nested + class Create { + + @DisplayName("이름과 설명이 유효하면, 정상적으로 생성된다.") + @Test + void createsBrand_whenAllFieldsAreValid() { + // Arrange + String name = "Nike"; + String description = "Just Do It"; + + // Act + Brand brand = Brand.create(name, description); + + // Assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo(name), + () -> assertThat(brand.getDescription()).isEqualTo(description), + () -> assertThat(brand.getLikeCount()).isZero() + ); + } + + @DisplayName("설명이 null이어도, 정상적으로 생성된다.") + @Test + void createsBrand_whenDescriptionIsNull() { + // Arrange + String name = "Nike"; + + // Act + Brand brand = Brand.create(name, null); + + // Assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo(name), + () -> assertThat(brand.getDescription()).isNull() + ); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Brand.create(null, "설명"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + Brand.create(" ", "설명"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("브랜드를 수정할 때, ") + @Nested + class Update { + + @DisplayName("이름과 설명을 변경하면, 정상적으로 수정된다.") + @Test + void updatesBrand_whenFieldsAreValid() { + // Arrange + Brand brand = Brand.create("Nike", "Just Do It"); + + // Act + brand.update("Adidas", "Impossible Is Nothing"); + + // Assert + assertAll( + () -> assertThat(brand.getName()).isEqualTo("Adidas"), + () -> assertThat(brand.getDescription()).isEqualTo("Impossible Is Nothing") + ); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // Arrange + Brand brand = Brand.create("Nike", "Just Do It"); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + brand.update(null, "설명"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // Arrange + Brand brand = Brand.create("Nike", "Just Do It"); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + brand.update(" ", "설명"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("좋아요 수를 관리할 때, ") + @Nested + class LikeCount { + + @DisplayName("좋아요를 증가시키면, 1 증가한다.") + @Test + void increasesLikeCount_whenCalled() { + // Arrange + Brand brand = Brand.create("Nike", "Just Do It"); + + // Act + brand.increaseLikeCount(); + + // Assert + assertThat(brand.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요를 연속으로 증가시키면, 호출 횟수만큼 증가한다.") + @Test + void increasesLikeCount_whenCalledMultipleTimes() { + // Arrange + Brand brand = Brand.create("Nike", "Just Do It"); + + // Act + brand.increaseLikeCount(); + brand.increaseLikeCount(); + brand.increaseLikeCount(); + + // Assert + assertThat(brand.getLikeCount()).isEqualTo(3); + } + + @DisplayName("좋아요를 감소시키면, 1 감소한다.") + @Test + void decreasesLikeCount_whenCalled() { + // Arrange + Brand brand = Brand.create("Nike", "Just Do It"); + brand.increaseLikeCount(); + + // Act + brand.decreaseLikeCount(); + + // Assert + assertThat(brand.getLikeCount()).isZero(); + } + + @DisplayName("좋아요가 0일 때 감소시키면, 0 미만으로 내려가지 않는다.") + @Test + void doesNotGoBelowZero_whenDecreasedAtZero() { + // Arrange + Brand brand = Brand.create("Nike", "Just Do It"); + + // Act + brand.decreaseLikeCount(); + + // Assert + assertThat(brand.getLikeCount()).isZero(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java new file mode 100644 index 000000000..e1f4fe748 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -0,0 +1,160 @@ +package com.loopers.domain.like; + +import com.loopers.domain.PageResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import com.loopers.domain.PageResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class LikeServiceTest { + + private LikeService likeService; + private FakeLikeReader fakeLikeReader; + private FakeLikeRepository fakeLikeRepository; + + @BeforeEach + void setUp() { + fakeLikeReader = new FakeLikeReader(); + fakeLikeRepository = new FakeLikeRepository(); + likeService = new LikeService(fakeLikeReader, fakeLikeRepository); + } + + @DisplayName("좋아요를 토글할 때, ") + @Nested + class ToggleLike { + + @DisplayName("좋아요가 없으면, 좋아요를 생성하고 true를 반환한다.") + @Test + void returnsTrue_whenLikeNotExists() { + // Act + boolean result = likeService.toggleLike(1L, LikeTargetType.PRODUCT, 100L); + + // Assert + assertThat(result).isTrue(); + assertThat(fakeLikeRepository.getSavedLikes()).hasSize(1); + } + + @DisplayName("좋아요가 이미 있으면, 좋아요를 삭제하고 false를 반환한다.") + @Test + void returnsFalse_whenLikeAlreadyExists() { + // Arrange + Like existingLike = Like.create(1L, LikeTargetType.PRODUCT, 100L); + fakeLikeReader.addLike(existingLike); + fakeLikeRepository.addLike(existingLike); + + // Act + boolean result = likeService.toggleLike(1L, LikeTargetType.PRODUCT, 100L); + + // Assert + assertThat(result).isFalse(); + assertThat(fakeLikeRepository.getDeletedLikes()).hasSize(1); + } + + @DisplayName("상품 좋아요와 브랜드 좋아요는 독립적으로 동작한다.") + @Test + void operatesIndependently_whenDifferentTargetTypes() { + // Act + boolean productResult = likeService.toggleLike(1L, LikeTargetType.PRODUCT, 100L); + boolean brandResult = likeService.toggleLike(1L, LikeTargetType.BRAND, 100L); + + // Assert + assertThat(productResult).isTrue(); + assertThat(brandResult).isTrue(); + assertThat(fakeLikeRepository.getSavedLikes()).hasSize(2); + } + } + + @DisplayName("내 좋아요 목록을 조회할 때, ") + @Nested + class GetMyLikes { + + @DisplayName("좋아요한 항목이 있으면, 페이징된 결과를 반환한다.") + @Test + void returnsPagedResult_whenLikesExist() { + // Arrange + fakeLikeReader.addLike(Like.create(1L, LikeTargetType.PRODUCT, 100L)); + fakeLikeReader.addLike(Like.create(1L, LikeTargetType.PRODUCT, 200L)); + fakeLikeReader.addLike(Like.create(1L, LikeTargetType.BRAND, 50L)); + + // Act + PageResult result = likeService.getMyLikes(1L, LikeTargetType.PRODUCT, 0, 20); + + // Assert + assertThat(result.content()).hasSize(2); + assertThat(result.totalElements()).isEqualTo(2); + } + + @DisplayName("좋아요한 항목이 없으면, 빈 결과를 반환한다.") + @Test + void returnsEmptyResult_whenNoLikes() { + // Act + PageResult result = likeService.getMyLikes(1L, LikeTargetType.PRODUCT, 0, 20); + + // Assert + assertThat(result.content()).isEmpty(); + assertThat(result.totalElements()).isZero(); + } + } + + // Fake 구현체 + static class FakeLikeReader implements LikeReader { + private final List likes = new ArrayList<>(); + + void addLike(Like like) { + likes.add(like); + } + + @Override + public Optional findByMemberIdAndTargetTypeAndTargetId(Long memberId, LikeTargetType targetType, Long targetId) { + return likes.stream() + .filter(l -> l.getMemberId().equals(memberId) + && l.getTargetType() == targetType + && l.getTargetId().equals(targetId)) + .findFirst(); + } + + @Override + public PageResult findAllByMemberIdAndTargetType(Long memberId, LikeTargetType targetType, int page, int size) { + List filtered = likes.stream() + .filter(l -> l.getMemberId().equals(memberId) && l.getTargetType() == targetType) + .toList(); + return new PageResult<>(filtered, filtered.size(), 1, page, size); + } + } + + static class FakeLikeRepository implements LikeRepository { + private final List savedLikes = new ArrayList<>(); + private final List deletedLikes = new ArrayList<>(); + + void addLike(Like like) { + savedLikes.add(like); + } + + List getSavedLikes() { + return savedLikes; + } + + List getDeletedLikes() { + return deletedLikes; + } + + @Override + public Like save(Like like) { + savedLikes.add(like); + return like; + } + + @Override + public void delete(Like like) { + deletedLikes.add(like); + savedLikes.remove(like); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..089c5322d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,77 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LikeTest { + + @DisplayName("좋아요를 생성할 때, ") + @Nested + class Create { + + @DisplayName("모든 필드가 유효하면, 정상적으로 생성된다.") + @Test + void createsLike_whenAllFieldsAreValid() { + // Arrange & Act + Like like = Like.create(1L, LikeTargetType.PRODUCT, 100L); + + // Assert + assertAll( + () -> assertThat(like.getMemberId()).isEqualTo(1L), + () -> assertThat(like.getTargetType()).isEqualTo(LikeTargetType.PRODUCT), + () -> assertThat(like.getTargetId()).isEqualTo(100L) + ); + } + + @DisplayName("BRAND 타입으로도 정상적으로 생성된다.") + @Test + void createsLike_whenTargetTypeIsBrand() { + // Arrange & Act + Like like = Like.create(1L, LikeTargetType.BRAND, 50L); + + // Assert + assertAll( + () -> assertThat(like.getMemberId()).isEqualTo(1L), + () -> assertThat(like.getTargetType()).isEqualTo(LikeTargetType.BRAND), + () -> assertThat(like.getTargetId()).isEqualTo(50L) + ); + } + + @DisplayName("memberId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenMemberIdIsNull() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Like.create(null, LikeTargetType.PRODUCT, 100L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("targetType이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenTargetTypeIsNull() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Like.create(1L, null, 100L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("targetId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenTargetIdIsNull() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Like.create(1L, LikeTargetType.PRODUCT, null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java index b24151b7d..369122855 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberServiceTest.java @@ -6,9 +6,11 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import com.loopers.domain.PageResult; import java.time.LocalDate; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -26,7 +28,7 @@ class MemberServiceTest { @BeforeEach void setUp() { fakeMemberReader = new FakeMemberReader(); - fakeMemberRepository = new FakeMemberRepository(); + fakeMemberRepository = new FakeMemberRepository(fakeMemberReader); stubPasswordEncoder = new StubPasswordEncoder(); memberService = new MemberService(fakeMemberReader, fakeMemberRepository, stubPasswordEncoder); } @@ -46,7 +48,7 @@ void throwsBadRequest_whenLoginIdAlreadyExists() { CoreException exception = assertThrows(CoreException.class, () -> { memberService.register( existingLoginId, "Test1234!", "홍길동", - LocalDate.of(1990, 1, 15), "test@example.com" + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null ); }); @@ -65,27 +67,91 @@ void savesMember_whenAllFieldsAreValid() { String email = "test@example.com"; // Act - Member member = memberService.register(loginId, password, name, birthDate, email); + Member member = memberService.register(loginId, password, name, birthDate, Gender.MALE, email, null); // Assert assertAll( () -> assertThat(member.getLoginId()).isEqualTo(loginId), - () -> assertThat(member.getPassword()).isEqualTo("encoded_" + password), + () -> assertThat(member.verifyPassword(password, stubPasswordEncoder)).isTrue(), () -> assertThat(member.getName()).isEqualTo(name), () -> assertThat(member.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(member.getGender()).isEqualTo(Gender.MALE), () -> assertThat(member.getEmail()).isEqualTo(email) ); } } + @DisplayName("전화번호 수정 시, ") + @Nested + class UpdatePhone { + + @DisplayName("유효한 전화번호로 수정하면, 정상적으로 변경된다.") + @Test + void updatesPhone_whenValidFormat() { + // Arrange + Member member = memberService.register( + "testUser", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null + ); + + // Act + memberService.updatePhone("testUser", "010-9999-8888"); + + // Assert + assertThat(member.getPhone()).isEqualTo("010-9999-8888"); + } + } + + @DisplayName("회원 탈퇴 시, ") + @Nested + class Withdraw { + + @DisplayName("비밀번호가 일치하면, soft delete 처리된다.") + @Test + void deleteMember_whenPasswordMatches() { + // Arrange + Member member = memberService.register( + "testUser", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null + ); + + // Act + memberService.withdraw("testUser", "Test1234!"); + + // Assert + assertThat(member.getDeletedAt()).isNotNull(); + } + + @DisplayName("비밀번호가 일치하지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordDoesNotMatch() { + // Arrange + memberService.register( + "testUser", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null + ); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> { + memberService.withdraw("testUser", "WrongPass1!"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + // Fake 구현체 static class FakeMemberReader implements MemberReader { private final Map existingLoginIds = new HashMap<>(); + private final Map members = new HashMap<>(); void addExistingLoginId(String loginId) { existingLoginIds.put(loginId, true); } + void addMember(Member member) { + members.put(member.getLoginId(), member); + } + @Override public boolean existsByLoginId(String loginId) { return existingLoginIds.containsKey(loginId); @@ -93,17 +159,33 @@ public boolean existsByLoginId(String loginId) { @Override public Optional findByLoginId(String loginId) { + return Optional.ofNullable(members.get(loginId)); + } + + @Override + public Optional findById(Long id) { return Optional.empty(); } + + @Override + public PageResult findAll(String keyword, int page, int size) { + return new PageResult<>(List.of(), 0, 0, page, size); + } } static class FakeMemberRepository implements MemberRepository { private final Map members = new HashMap<>(); + private final FakeMemberReader fakeMemberReader; private long idSequence = 1L; + FakeMemberRepository(FakeMemberReader fakeMemberReader) { + this.fakeMemberReader = fakeMemberReader; + } + @Override public Member save(Member member) { members.put(idSequence++, member); + fakeMemberReader.addMember(member); return member; } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java index 12c0b6521..7821601a5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -27,6 +27,13 @@ public boolean matches(String rawPassword, String encodedPassword) { } }; + private Member createDefaultMember() { + return Member.create( + "testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder + ); + } + @DisplayName("필수값 검증 시, ") @Nested class ValidateRequired { @@ -36,7 +43,7 @@ class ValidateRequired { void throwsBadRequest_whenLoginIdIsNull() { CoreException exception = assertThrows(CoreException.class, () -> { Member.create(null, "Test1234!", "홍길동", - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder); }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); assertThat(exception.getMessage()).isEqualTo("로그인ID는 필수입니다."); @@ -47,7 +54,7 @@ void throwsBadRequest_whenLoginIdIsNull() { void throwsBadRequest_whenLoginIdIsBlank() { CoreException exception = assertThrows(CoreException.class, () -> { Member.create(" ", "Test1234!", "홍길동", - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder); }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); assertThat(exception.getMessage()).isEqualTo("로그인ID는 필수입니다."); @@ -58,7 +65,7 @@ void throwsBadRequest_whenLoginIdIsBlank() { void throwsBadRequest_whenPasswordIsNull() { CoreException exception = assertThrows(CoreException.class, () -> { Member.create("testuser1", null, "홍길동", - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder); }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); assertThat(exception.getMessage()).isEqualTo("비밀번호는 필수입니다."); @@ -69,7 +76,7 @@ void throwsBadRequest_whenPasswordIsNull() { void throwsBadRequest_whenNameIsNull() { CoreException exception = assertThrows(CoreException.class, () -> { Member.create("testuser1", "Test1234!", null, - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder); + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder); }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); assertThat(exception.getMessage()).isEqualTo("이름은 필수입니다."); @@ -80,18 +87,29 @@ void throwsBadRequest_whenNameIsNull() { void throwsBadRequest_whenBirthDateIsNull() { CoreException exception = assertThrows(CoreException.class, () -> { Member.create("testuser1", "Test1234!", "홍길동", - null, "test@example.com", stubEncoder); + null, Gender.MALE, "test@example.com", null, stubEncoder); }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); assertThat(exception.getMessage()).isEqualTo("생년월일은 필수입니다."); } + @DisplayName("성별이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenGenderIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create("testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), null, "test@example.com", null, stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("성별은 필수입니다."); + } + @DisplayName("이메일이 null이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenEmailIsNull() { CoreException exception = assertThrows(CoreException.class, () -> { Member.create("testuser1", "Test1234!", "홍길동", - LocalDate.of(1990, 1, 15), null, stubEncoder); + LocalDate.of(1990, 1, 15), Gender.MALE, null, null, stubEncoder); }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); assertThat(exception.getMessage()).isEqualTo("이메일은 필수입니다."); @@ -105,25 +123,39 @@ class Create { @DisplayName("모든 정보가 유효하면, 정상적으로 생성된다.") @Test void createsMember_whenAllFieldsAreValid() { - // Arrange - String loginId = "testuser1"; - String password = "Test1234!"; - String name = "홍길동"; - LocalDate birthDate = LocalDate.of(1990, 1, 15); - String email = "test@example.com"; - - // Act + // Arrange & Act + Member member = Member.create( + "testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", "010-1234-5678", + stubEncoder + ); + + // Assert + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo("testuser1"), + () -> assertThat(member.verifyPassword("Test1234!", stubEncoder)).isTrue(), + () -> assertThat(member.getName()).isEqualTo("홍길동"), + () -> assertThat(member.getBirthDate()).isEqualTo(LocalDate.of(1990, 1, 15)), + () -> assertThat(member.getGender()).isEqualTo(Gender.MALE), + () -> assertThat(member.getEmail()).isEqualTo("test@example.com"), + () -> assertThat(member.getPhone()).isEqualTo("010-1234-5678") + ); + } + + @DisplayName("전화번호 없이 생성하면, phone이 null이다.") + @Test + void createsMember_whenPhoneIsNull() { + // Arrange & Act Member member = Member.create( - loginId, password, name, birthDate, email, stubEncoder + "testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.FEMALE, "test@example.com", null, + stubEncoder ); // Assert assertAll( - () -> assertThat(member.getLoginId()).isEqualTo(loginId), - () -> assertThat(member.getPassword()).isEqualTo("encoded_" + password), // Stub이 반환한 값 - () -> assertThat(member.getName()).isEqualTo(name), - () -> assertThat(member.getBirthDate()).isEqualTo(birthDate), - () -> assertThat(member.getEmail()).isEqualTo(email) + () -> assertThat(member.getGender()).isEqualTo(Gender.FEMALE), + () -> assertThat(member.getPhone()).isNull() ); } } @@ -135,18 +167,12 @@ class ValidateLoginId { @DisplayName("영문과 숫자 외 문자가 포함되면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { - // Arrange - String invalidLoginId = "test@user"; - - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { Member.create( - invalidLoginId, "Test1234!", "홍길동", - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + "test@user", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder ); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } @@ -158,73 +184,48 @@ class ValidatePassword { @DisplayName("8자 미만이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenPasswordIsTooShort() { - // Arrange - String shortPassword = "Test12!"; // 7자 - - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { Member.create( - "testuser1", shortPassword, "홍길동", - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + "testuser1", "Test12!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder ); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @DisplayName("16자 초과이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenPasswordIsTooLong() { - // Arrange - String longPassword = "Test1234!Test1234"; // 17자 - - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { Member.create( - "testuser1", longPassword, "홍길동", - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + "testuser1", "Test1234!Test1234", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder ); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @DisplayName("허용되지 않은 문자(한글)가 포함되면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenPasswordContainsInvalidCharacters() { - // Arrange - String invalidPassword = "Test123한글!"; // 한글 포함 - - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { Member.create( - "testuser1", invalidPassword, "홍길동", - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + "testuser1", "Test123한글!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder ); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @DisplayName("생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenPasswordContainsBirthDate() { - // Arrange - LocalDate birthDate = LocalDate.of(1990, 1, 15); - String passwordWithBirthDate = "Test19900115!"; // 생년월일 포함 - - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { Member.create( - "testuser1", passwordWithBirthDate, "홍길동", - birthDate, "test@example.com", stubEncoder + "testuser1", "Test19900115!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder ); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } @@ -236,52 +237,34 @@ class ValidateName { @DisplayName("한글과 영문이 혼합되면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenNameContainsMixedLanguages() { - // Arrange - String mixedName = "Hong길동"; - - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { Member.create( - "testuser1", "Test1234!", mixedName, - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + "testuser1", "Test1234!", "Hong길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder ); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @DisplayName("한글 이름에 공백이 포함되면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenKoreanNameContainsSpace() { - // Arrange - String koreanNameWithSpace = "홍 길동"; - - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { Member.create( - "testuser1", "Test1234!", koreanNameWithSpace, - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + "testuser1", "Test1234!", "홍 길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder ); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @DisplayName("영문 이름의 연속 공백은 하나로 정규화된다.") @Test void normalizesConsecutiveSpaces_whenEnglishNameHasMultipleSpaces() { - // Arrange - String nameWithConsecutiveSpaces = "John Doe"; - - // Act Member member = Member.create( - "testuser1", "Test1234!", nameWithConsecutiveSpaces, - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + "testuser1", "Test1234!", "John Doe", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", null, stubEncoder ); - - // Assert assertThat(member.getName()).isEqualTo("John Doe"); } } @@ -293,45 +276,150 @@ class ValidateEmail { @DisplayName("올바르지 않은 형식이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenEmailFormatIsInvalid() { - // Arrange - String invalidEmail = "invalid-email"; + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "invalid-email", null, stubEncoder + ); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("전화번호 검증 시, ") + @Nested + class ValidatePhone { + + @DisplayName("올바른 형식이면, 정상적으로 생성된다.") + @Test + void createsMember_whenPhoneFormatIsValid() { + Member member = Member.create( + "testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", "010-1234-5678", + stubEncoder + ); + assertThat(member.getPhone()).isEqualTo("010-1234-5678"); + } - // Act & Assert + @DisplayName("올바르지 않은 형식이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPhoneFormatIsInvalid() { CoreException exception = assertThrows(CoreException.class, () -> { Member.create( "testuser1", "Test1234!", "홍길동", - LocalDate.of(1990, 1, 15), invalidEmail, stubEncoder + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", "01012345678", + stubEncoder ); }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("전화번호 형식이 올바르지 않습니다. (010-XXXX-XXXX)"); + } - // Assert + @DisplayName("하이픈 없는 번호이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPhoneHasNoHyphens() { + CoreException exception = assertThrows(CoreException.class, () -> { + Member.create( + "testuser1", "Test1234!", "홍길동", + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", "0101234567", + stubEncoder + ); + }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } - @DisplayName("비밀번호 변경 시, ") + @DisplayName("전화번호 수정 시, ") @Nested - class ChangePassword { + class UpdatePhone { + + @DisplayName("유효한 전화번호로 수정하면, 정상적으로 변경된다.") + @Test + void updatesPhone_whenValidFormat() { + Member member = createDefaultMember(); + + member.updatePhone("010-9999-8888"); - private Member createMember() { - return Member.create( + assertThat(member.getPhone()).isEqualTo("010-9999-8888"); + } + + @DisplayName("null로 수정하면, 전화번호가 삭제된다.") + @Test + void removesPhone_whenNull() { + Member member = Member.create( "testuser1", "Test1234!", "홍길동", - LocalDate.of(1990, 1, 15), "test@example.com", stubEncoder + LocalDate.of(1990, 1, 15), Gender.MALE, "test@example.com", "010-1234-5678", + stubEncoder ); + + member.updatePhone(null); + + assertThat(member.getPhone()).isNull(); + } + + @DisplayName("올바르지 않은 형식이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenInvalidFormat() { + Member member = createDefaultMember(); + + CoreException exception = assertThrows(CoreException.class, () -> { + member.updatePhone("invalid"); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("회원 탈퇴 시, ") + @Nested + class Withdraw { + + @DisplayName("비밀번호가 일치하면, soft delete 처리된다.") + @Test + void deletedMember_whenPasswordMatches() { + Member member = createDefaultMember(); + + member.withdraw("Test1234!", stubEncoder); + + assertThat(member.getDeletedAt()).isNotNull(); + } + + @DisplayName("비밀번호가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsNull() { + Member member = createDefaultMember(); + + CoreException exception = assertThrows(CoreException.class, () -> { + member.withdraw(null, stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("비밀번호는 필수입니다."); } + @DisplayName("비밀번호가 일치하지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordDoesNotMatch() { + Member member = createDefaultMember(); + + CoreException exception = assertThrows(CoreException.class, () -> { + member.withdraw("WrongPass1!", stubEncoder); + }); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).isEqualTo("비밀번호가 일치하지 않습니다."); + } + } + + @DisplayName("비밀번호 변경 시, ") + @Nested + class ChangePassword { + @DisplayName("현재 비밀번호가 null이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenCurrentPasswordIsNull() { - // Arrange - Member member = createMember(); + Member member = createDefaultMember(); - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { member.changePassword(null, "NewPass5678!", stubEncoder); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); assertThat(exception.getMessage()).isEqualTo("현재 비밀번호는 필수입니다."); } @@ -339,15 +427,11 @@ void throwsBadRequest_whenCurrentPasswordIsNull() { @DisplayName("새 비밀번호가 null이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenNewPasswordIsNull() { - // Arrange - Member member = createMember(); + Member member = createDefaultMember(); - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { member.changePassword("Test1234!", null, stubEncoder); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); assertThat(exception.getMessage()).isEqualTo("새 비밀번호는 필수입니다."); } @@ -355,17 +439,11 @@ void throwsBadRequest_whenNewPasswordIsNull() { @DisplayName("현재 비밀번호가 일치하지 않으면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenCurrentPasswordDoesNotMatch() { - // Arrange - Member member = createMember(); - String wrongCurrentPassword = "WrongPass1!"; - String newPassword = "NewPass5678!"; + Member member = createDefaultMember(); - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - member.changePassword(wrongCurrentPassword, newPassword, stubEncoder); + member.changePassword("WrongPass1!", "NewPass5678!", stubEncoder); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); assertThat(exception.getMessage()).isEqualTo("현재 비밀번호가 일치하지 않습니다."); } @@ -373,17 +451,11 @@ void throwsBadRequest_whenCurrentPasswordDoesNotMatch() { @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenNewPasswordIsSameAsCurrent() { - // Arrange - Member member = createMember(); - String currentPassword = "Test1234!"; - String samePassword = "Test1234!"; + Member member = createDefaultMember(); - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - member.changePassword(currentPassword, samePassword, stubEncoder); + member.changePassword("Test1234!", "Test1234!", stubEncoder); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); assertThat(exception.getMessage()).isEqualTo("새 비밀번호는 현재 비밀번호와 달라야 합니다."); } @@ -391,34 +463,22 @@ void throwsBadRequest_whenNewPasswordIsSameAsCurrent() { @DisplayName("새 비밀번호가 규칙을 위반하면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenNewPasswordViolatesRules() { - // Arrange - Member member = createMember(); - String currentPassword = "Test1234!"; - String shortPassword = "short"; // 8자 미만 + Member member = createDefaultMember(); - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - member.changePassword(currentPassword, shortPassword, stubEncoder); + member.changePassword("Test1234!", "short", stubEncoder); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @DisplayName("새 비밀번호에 생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenNewPasswordContainsBirthDate() { - // Arrange - Member member = createMember(); - String currentPassword = "Test1234!"; - String passwordWithBirthDate = "Pass19900115!"; // 생년월일 포함 + Member member = createDefaultMember(); - // Act & Assert CoreException exception = assertThrows(CoreException.class, () -> { - member.changePassword(currentPassword, passwordWithBirthDate, stubEncoder); + member.changePassword("Test1234!", "Pass19900115!", stubEncoder); }); - - // Assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); assertThat(exception.getMessage()).isEqualTo("비밀번호에 생년월일을 포함할 수 없습니다."); } @@ -426,16 +486,11 @@ void throwsBadRequest_whenNewPasswordContainsBirthDate() { @DisplayName("모든 조건이 유효하면, 비밀번호가 정상적으로 변경된다.") @Test void changesPassword_whenAllConditionsAreValid() { - // Arrange - Member member = createMember(); - String currentPassword = "Test1234!"; - String newPassword = "NewPass5678!"; + Member member = createDefaultMember(); - // Act - member.changePassword(currentPassword, newPassword, stubEncoder); + member.changePassword("Test1234!", "NewPass5678!", stubEncoder); - // Assert - assertThat(member.getPassword()).isEqualTo("encoded_" + newPassword); + assertThat(member.verifyPassword("NewPass5678!", stubEncoder)).isTrue(); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..927b85c07 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,89 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderItemTest { + + @DisplayName("주문 항목을 생성할 때, ") + @Nested + class Create { + + @DisplayName("모든 필드가 유효하면, 정상적으로 생성된다.") + @Test + void createsOrderItem_whenAllFieldsAreValid() { + // Arrange & Act + OrderItem item = OrderItem.create(1L, 10L, "에어맥스 90", 139000L, 2); + + // Assert + assertAll( + () -> assertThat(item.getOrderId()).isEqualTo(1L), + () -> assertThat(item.getProductId()).isEqualTo(10L), + () -> assertThat(item.getProductName()).isEqualTo("에어맥스 90"), + () -> assertThat(item.getProductPrice()).isEqualTo(139000L), + () -> assertThat(item.getQuantity()).isEqualTo(2) + ); + } + + @DisplayName("quantity가 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsZero() { + CoreException exception = assertThrows(CoreException.class, () -> + OrderItem.create(1L, 10L, "에어맥스 90", 139000L, 0) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("quantity가 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsNegative() { + CoreException exception = assertThrows(CoreException.class, () -> + OrderItem.create(1L, 10L, "에어맥스 90", 139000L, -1) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productName이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductNameIsBlank() { + CoreException exception = assertThrows(CoreException.class, () -> + OrderItem.create(1L, 10L, "", 139000L, 2) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productPrice가 0 이하이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductPriceIsNotPositive() { + CoreException exception = assertThrows(CoreException.class, () -> + OrderItem.create(1L, 10L, "에어맥스 90", 0L, 2) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("소계를 계산할 때, ") + @Nested + class GetSubtotal { + + @DisplayName("productPrice * quantity를 반환한다.") + @Test + void returnsProductPriceTimesQuantity() { + // Arrange + OrderItem item = OrderItem.create(1L, 10L, "에어맥스 90", 139000L, 3); + + // Act + Long subtotal = item.getSubtotal(); + + // Assert + assertThat(subtotal).isEqualTo(417000L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java new file mode 100644 index 000000000..94b22b3c7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,370 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import com.loopers.domain.PageResult; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderServiceTest { + + private OrderService orderService; + private FakeOrderRepository fakeOrderRepository; + private FakeOrderReader fakeOrderReader; + private FakeOrderItemRepository fakeOrderItemRepository; + private FakeOrderItemReader fakeOrderItemReader; + + @BeforeEach + void setUp() { + fakeOrderRepository = new FakeOrderRepository(); + fakeOrderReader = new FakeOrderReader(); + fakeOrderItemRepository = new FakeOrderItemRepository(); + fakeOrderItemReader = new FakeOrderItemReader(); + orderService = new OrderService(fakeOrderRepository, fakeOrderReader, fakeOrderItemRepository, fakeOrderItemReader); + } + + @DisplayName("주문을 생성할 때, ") + @Nested + class CreateOrder { + + @DisplayName("정상적으로 주문이 저장된다.") + @Test + void savesOrder() { + // Act + Order order = orderService.createOrder( + 1L, "홍길동", "010-1234-5678", "12345", "서울시 강남구", null, 258000L + ); + + // Assert + assertAll( + () -> assertThat(order.getMemberId()).isEqualTo(1L), + () -> assertThat(order.getTotalAmount()).isEqualTo(258000L), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.COMPLETED), + () -> assertThat(fakeOrderRepository.getSavedOrders()).hasSize(1) + ); + } + } + + @DisplayName("주문 항목을 생성할 때, ") + @Nested + class CreateOrderItems { + + @DisplayName("정상적으로 주문 항목들이 저장된다.") + @Test + void savesOrderItems() { + // Arrange + List commands = List.of( + new OrderService.OrderItemCommand(10L, "에어맥스 90", 139000L, 1), + new OrderService.OrderItemCommand(20L, "에어포스 1", 119000L, 2) + ); + + // Act + List items = orderService.createOrderItems(1L, commands); + + // Assert + assertAll( + () -> assertThat(items).hasSize(2), + () -> assertThat(items.get(0).getProductName()).isEqualTo("에어맥스 90"), + () -> assertThat(items.get(1).getQuantity()).isEqualTo(2) + ); + } + } + + @DisplayName("주문을 조회할 때, ") + @Nested + class GetOrder { + + @DisplayName("존재하는 주문이면, 주문을 반환한다.") + @Test + void returnsOrder_whenExists() { + // Arrange + Order order = Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L); + fakeOrderReader.addOrder(order); + + // Act + Order found = orderService.getOrder(order.getId()); + + // Assert + assertThat(found.getMemberId()).isEqualTo(1L); + } + + @DisplayName("존재하지 않는 주문이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotExists() { + CoreException exception = assertThrows(CoreException.class, () -> + orderService.getOrder(999L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("회원 주문을 조회할 때, ") + @Nested + class GetOrderForMember { + + @DisplayName("소유권이 일치하면, 주문을 반환한다.") + @Test + void returnsOrder_whenOwnerMatches() { + // Arrange + Order order = Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L); + fakeOrderReader.addOrder(order); + + // Act + Order found = orderService.getOrderForMember(order.getId(), 1L); + + // Assert + assertThat(found.getMemberId()).isEqualTo(1L); + } + + @DisplayName("소유권이 불일치하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenOwnerMismatch() { + // Arrange + Order order = Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L); + fakeOrderReader.addOrder(order); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + orderService.getOrderForMember(order.getId(), 999L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("주문 항목을 병합할 때, ") + @Nested + class MergeOrderItems { + + @DisplayName("중복 상품은 수량이 합산된다.") + @Test + void mergesDuplicateProducts() { + // Arrange + List requests = List.of( + new OrderService.OrderItemRequest(1L, 2), + new OrderService.OrderItemRequest(2L, 1), + new OrderService.OrderItemRequest(1L, 3) + ); + + // Act + Map merged = orderService.mergeOrderItems(requests); + + // Assert + assertAll( + () -> assertThat(merged).hasSize(2), + () -> assertThat(merged.get(1L)).isEqualTo(5), + () -> assertThat(merged.get(2L)).isEqualTo(1) + ); + } + + @DisplayName("항목이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenEmpty() { + CoreException exception = assertThrows(CoreException.class, () -> + orderService.mergeOrderItems(List.of()) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("항목이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNull() { + CoreException exception = assertThrows(CoreException.class, () -> + orderService.mergeOrderItems(null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 0 이하이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsZeroOrNegative() { + List requests = List.of( + new OrderService.OrderItemRequest(1L, 0) + ); + + CoreException exception = assertThrows(CoreException.class, () -> + orderService.mergeOrderItems(requests) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문을 취소할 때, ") + @Nested + class CancelOrder { + + @DisplayName("정상적으로 취소되고, 주문 항목을 반환한다.") + @Test + void cancelsOrder_andReturnsItems() { + // Arrange + Order order = Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L); + fakeOrderReader.addOrder(order); + OrderItem item = OrderItem.create(order.getId(), 10L, "에어맥스", 100000L, 1); + fakeOrderItemReader.addItem(item); + + // Act + List items = orderService.cancelOrder(order.getId(), 1L); + + // Assert + assertAll( + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED), + () -> assertThat(items).hasSize(1) + ); + } + + @DisplayName("이미 취소된 주문이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenAlreadyCancelled() { + // Arrange + Order order = Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L); + order.cancel(); + fakeOrderReader.addOrder(order); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + orderService.cancelOrder(order.getId(), 1L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("소유권이 불일치하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenOwnerMismatch() { + // Arrange + Order order = Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L); + fakeOrderReader.addOrder(order); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + orderService.cancelOrder(order.getId(), 999L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("내 주문 목록을 조회할 때, ") + @Nested + class GetMyOrders { + + @DisplayName("주문이 있으면, 페이징된 결과를 반환한다.") + @Test + void returnsPagedResult_whenOrdersExist() { + // Arrange + fakeOrderReader.addOrder(Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L)); + fakeOrderReader.addOrder(Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, 200000L)); + fakeOrderReader.addOrder(Order.create(2L, "김철수", "010-9999-9999", "54321", "다른주소", null, 50000L)); + + // Act + PageResult result = orderService.getMyOrders(1L, null, null, 0, 20); + + // Assert + assertThat(result.content()).hasSize(2); + assertThat(result.totalElements()).isEqualTo(2); + } + + @DisplayName("주문이 없으면, 빈 결과를 반환한다.") + @Test + void returnsEmptyResult_whenNoOrders() { + // Act + PageResult result = orderService.getMyOrders(1L, null, null, 0, 20); + + // Assert + assertThat(result.content()).isEmpty(); + } + } + + // --- Fake 구현체 --- + + static class FakeOrderRepository implements OrderRepository { + private final List savedOrders = new ArrayList<>(); + + List getSavedOrders() { return savedOrders; } + + @Override + public Order save(Order order) { + savedOrders.add(order); + return order; + } + } + + static class FakeOrderReader implements OrderReader { + private final List orders = new ArrayList<>(); + + void addOrder(Order order) { orders.add(order); } + + @Override + public Optional findById(Long id) { + return orders.stream() + .filter(o -> o.getId().equals(id)) + .findFirst(); + } + + @Override + public Optional findByIdAndMemberId(Long id, Long memberId) { + return orders.stream() + .filter(o -> o.getId().equals(id) && o.getMemberId().equals(memberId)) + .findFirst(); + } + + @Override + public PageResult findAllByMemberId(Long memberId, LocalDate startAt, LocalDate endAt, int page, int size) { + List filtered = orders.stream() + .filter(o -> o.getMemberId().equals(memberId)) + .toList(); + return new PageResult<>(filtered, filtered.size(), 1, page, size); + } + + @Override + public PageResult findAll(Long memberId, int page, int size) { + List filtered = memberId != null + ? orders.stream().filter(o -> o.getMemberId().equals(memberId)).toList() + : orders; + return new PageResult<>(filtered, filtered.size(), 1, page, size); + } + } + + static class FakeOrderItemRepository implements OrderItemRepository { + private final List savedItems = new ArrayList<>(); + + @Override + public OrderItem save(OrderItem orderItem) { + savedItems.add(orderItem); + return orderItem; + } + + @Override + public List saveAll(List orderItems) { + savedItems.addAll(orderItems); + return orderItems; + } + } + + static class FakeOrderItemReader implements OrderItemReader { + private final List items = new ArrayList<>(); + + void addItem(OrderItem item) { items.add(item); } + + @Override + public List findAllByOrderId(Long orderId) { + return items.stream().filter(i -> i.getOrderId().equals(orderId)).toList(); + } + + @Override + public List findAllByOrderIds(List orderIds) { + return items.stream().filter(i -> orderIds.contains(i.getOrderId())).toList(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..d90eafb91 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,157 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderTest { + + @DisplayName("주문을 생성할 때, ") + @Nested + class Create { + + @DisplayName("모든 필드가 유효하면, COMPLETED 상태로 생성된다.") + @Test + void createsOrder_withCompletedStatus() { + // Arrange & Act + Order order = Order.create( + 1L, "홍길동", "010-1234-5678", "12345", + "서울시 강남구 테헤란로 123", "101동 202호", 258000L + ); + + // Assert + assertAll( + () -> assertThat(order.getMemberId()).isEqualTo(1L), + () -> assertThat(order.getRecipientName()).isEqualTo("홍길동"), + () -> assertThat(order.getRecipientPhone()).isEqualTo("010-1234-5678"), + () -> assertThat(order.getZipCode()).isEqualTo("12345"), + () -> assertThat(order.getAddress1()).isEqualTo("서울시 강남구 테헤란로 123"), + () -> assertThat(order.getAddress2()).isEqualTo("101동 202호"), + () -> assertThat(order.getTotalAmount()).isEqualTo(258000L), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.COMPLETED) + ); + } + + @DisplayName("address2가 null이어도 정상 생성된다.") + @Test + void createsOrder_whenAddress2IsNull() { + // Arrange & Act + Order order = Order.create( + 1L, "홍길동", "010-1234-5678", "12345", + "서울시 강남구 테헤란로 123", null, 100000L + ); + + // Assert + assertThat(order.getAddress2()).isNull(); + } + + @DisplayName("memberId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenMemberIdIsNull() { + CoreException exception = assertThrows(CoreException.class, () -> + Order.create(null, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("recipientName이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenRecipientNameIsBlank() { + CoreException exception = assertThrows(CoreException.class, () -> + Order.create(1L, "", "010-1234-5678", "12345", "주소", null, 100000L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("totalAmount가 0 이하이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenTotalAmountIsNotPositive() { + CoreException exception = assertThrows(CoreException.class, () -> + Order.create(1L, "홍길동", "010-1234-5678", "12345", "주소", null, 0L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문을 취소할 때, ") + @Nested + class Cancel { + + @DisplayName("COMPLETED 상태이면, CANCELLED로 변경된다.") + @Test + void cancelsOrder_whenStatusIsCompleted() { + // Arrange + Order order = Order.create( + 1L, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L + ); + + // Act + order.cancel(); + + // Assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @DisplayName("이미 CANCELLED 상태이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAlreadyCancelled() { + // Arrange + Order order = Order.create( + 1L, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L + ); + order.cancel(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, order::cancel); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("배송지를 수정할 때, ") + @Nested + class UpdateShippingAddress { + + @DisplayName("COMPLETED 상태이면, 배송지 스냅샷이 수정된다.") + @Test + void updatesAddress_whenStatusIsCompleted() { + // Arrange + Order order = Order.create( + 1L, "홍길동", "010-1234-5678", "12345", "기존 주소", null, 100000L + ); + + // Act + order.updateShippingAddress("김철수", "010-9999-9999", "67890", "새 주소", "301동"); + + // Assert + assertAll( + () -> assertThat(order.getRecipientName()).isEqualTo("김철수"), + () -> assertThat(order.getRecipientPhone()).isEqualTo("010-9999-9999"), + () -> assertThat(order.getZipCode()).isEqualTo("67890"), + () -> assertThat(order.getAddress1()).isEqualTo("새 주소"), + () -> assertThat(order.getAddress2()).isEqualTo("301동") + ); + } + + @DisplayName("CANCELLED 상태이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStatusIsCancelled() { + // Arrange + Order order = Order.create( + 1L, "홍길동", "010-1234-5678", "12345", "주소", null, 100000L + ); + order.cancel(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + order.updateShippingAddress("김철수", "010-9999-9999", "67890", "새 주소", null) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..a9e22d17b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,289 @@ +package com.loopers.domain.product; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import com.loopers.domain.PageResult; + +import java.util.HashMap; +import java.util.List; +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; + +class ProductServiceTest { + + private ProductService productService; + private FakeProductReader fakeProductReader; + private FakeProductRepository fakeProductRepository; + + @BeforeEach + void setUp() { + fakeProductReader = new FakeProductReader(); + fakeProductRepository = new FakeProductRepository(fakeProductReader); + productService = new ProductService(fakeProductReader, fakeProductRepository); + } + + @DisplayName("상품을 등록할 때, ") + @Nested + class Register { + + @DisplayName("유효한 정보로 등록하면, 상품이 저장된다.") + @Test + void savesProduct_whenAllFieldsAreValid() { + // Act + Product product = productService.register(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Assert + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(1L), + () -> assertThat(product.getName()).isEqualTo("에어맥스 90"), + () -> assertThat(product.getPrice()).isEqualTo(139000L), + () -> assertThat(product.getStockQuantity()).isEqualTo(100), + () -> assertThat(product.getMaxOrderQuantity()).isEqualTo(5) + ); + } + } + + @DisplayName("상품을 조회할 때, ") + @Nested + class GetProduct { + + @DisplayName("존재하는 상품이면, 상품을 반환한다.") + @Test + void returnsProduct_whenProductExists() { + // Arrange + Product product = productService.register(1L, "에어맥스 90", "설명", 139000L, 100, 5); + fakeProductReader.addProduct(1L, product); + + // Act + Product found = productService.getProduct(1L); + + // Assert + assertThat(found.getName()).isEqualTo("에어맥스 90"); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + productService.getProduct(999L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품을 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 정보로 수정하면, 상품이 수정된다.") + @Test + void updatesProduct_whenFieldsAreValid() { + // Arrange + Product product = productService.register(1L, "에어맥스 90", "설명", 139000L, 100, 5); + fakeProductReader.addProduct(1L, product); + + // Act + productService.update(1L, "에어맥스 95", "수정된 설명", 149000L, 3); + + // Assert + assertAll( + () -> assertThat(product.getName()).isEqualTo("에어맥스 95"), + () -> assertThat(product.getDescription()).isEqualTo("수정된 설명"), + () -> assertThat(product.getPrice()).isEqualTo(149000L), + () -> assertThat(product.getMaxOrderQuantity()).isEqualTo(3) + ); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + productService.update(999L, "이름", "설명", 10000L, 5) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품을 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("존재하는 상품이면, soft delete 처리된다.") + @Test + void deletesProduct_whenProductExists() { + // Arrange + Product product = productService.register(1L, "에어맥스 90", "설명", 139000L, 100, 5); + fakeProductReader.addProduct(1L, product); + + // Act + productService.delete(1L); + + // Assert + assertThat(product.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + productService.delete(999L) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 재고를 수정할 때, ") + @Nested + class UpdateStock { + + @DisplayName("유효한 수량이면, 재고가 변경된다.") + @Test + void updatesStock_whenQuantityIsValid() { + // Arrange + Product product = productService.register(1L, "에어맥스 90", "설명", 139000L, 100, 5); + fakeProductReader.addProduct(1L, product); + + // Act + productService.updateStock(1L, 200); + + // Assert + assertThat(product.getStockQuantity()).isEqualTo(200); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductNotExists() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + productService.updateStock(999L, 200) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드별 상품을 삭제할 때, ") + @Nested + class DeleteAllByBrandId { + + @DisplayName("해당 브랜드의 모든 상품이 soft delete 처리된다.") + @Test + void deletesAllProducts_whenBrandIdMatches() { + // Arrange + Product product1 = productService.register(1L, "상품1", "설명", 10000L, 10, 5); + Product product2 = productService.register(1L, "상품2", "설명", 20000L, 20, 5); + fakeProductReader.addProductsByBrandId(1L, List.of(product1, product2)); + + // Act + productService.deleteAllByBrandId(1L); + + // Assert + assertAll( + () -> assertThat(product1.getDeletedAt()).isNotNull(), + () -> assertThat(product2.getDeletedAt()).isNotNull() + ); + } + + @DisplayName("해당 브랜드의 상품이 없으면, 아무 일도 일어나지 않는다.") + @Test + void doesNothing_whenNoProductsForBrand() { + // Act & Assert (no exception) + productService.deleteAllByBrandId(999L); + } + } + + @DisplayName("상품 목록을 조회할 때, ") + @Nested + class GetProducts { + + @DisplayName("상품이 존재하면, 페이징된 결과를 반환한다.") + @Test + void returnsPagedResult_whenProductsExist() { + // Arrange + Product product1 = productService.register(1L, "상품1", "설명", 10000L, 10, 5); + Product product2 = productService.register(1L, "상품2", "설명", 20000L, 20, 5); + fakeProductReader.setAllProducts(List.of(product1, product2)); + + // Act + PageResult result = productService.getProducts(null, null, ProductSortType.LATEST, 0, 20); + + // Assert + assertAll( + () -> assertThat(result.content()).hasSize(2), + () -> assertThat(result.page()).isZero(), + () -> assertThat(result.size()).isEqualTo(20) + ); + } + } + + // Fake 구현체 + static class FakeProductReader implements ProductReader { + private final Map products = new HashMap<>(); + private List allProducts = List.of(); + private final Map> productsByBrandId = new HashMap<>(); + + void addProduct(Long id, Product product) { + products.put(id, product); + } + + void setAllProducts(List products) { + this.allProducts = products; + } + + void addProductsByBrandId(Long brandId, List products) { + this.productsByBrandId.put(brandId, products); + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(products.get(id)); + } + + @Override + public PageResult findAll(String keyword, Long brandId, ProductSortType sort, int page, int size) { + return new PageResult<>(allProducts, allProducts.size(), 1, page, size); + } + + @Override + public List findAllByIds(List ids) { + return ids.stream() + .map(products::get) + .filter(java.util.Objects::nonNull) + .toList(); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productsByBrandId.getOrDefault(brandId, List.of()); + } + } + + static class FakeProductRepository implements ProductRepository { + private final Map products = new HashMap<>(); + private final FakeProductReader fakeProductReader; + private long idSequence = 1L; + + FakeProductRepository(FakeProductReader fakeProductReader) { + this.fakeProductReader = fakeProductReader; + } + + @Override + public Product save(Product product) { + long id = idSequence++; + products.put(id, product); + fakeProductReader.addProduct(id, product); + return product; + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..7667f10e2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,386 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductTest { + + @DisplayName("상품을 생성할 때, ") + @Nested + class Create { + + @DisplayName("모든 필드가 유효하면, 정상적으로 생성된다.") + @Test + void createsProduct_whenAllFieldsAreValid() { + // Arrange & Act + Product product = Product.create(1L, "에어맥스 90", "나이키 클래식 운동화", 139000L, 100, 5); + + // Assert + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(1L), + () -> assertThat(product.getName()).isEqualTo("에어맥스 90"), + () -> assertThat(product.getDescription()).isEqualTo("나이키 클래식 운동화"), + () -> assertThat(product.getPrice()).isEqualTo(139000L), + () -> assertThat(product.getStockQuantity()).isEqualTo(100), + () -> assertThat(product.getMaxOrderQuantity()).isEqualTo(5), + () -> assertThat(product.getLikeCount()).isZero() + ); + } + + @DisplayName("설명이 null이어도, 정상적으로 생성된다.") + @Test + void createsProduct_whenDescriptionIsNull() { + // Arrange & Act + Product product = Product.create(1L, "에어맥스 90", null, 139000L, 100, 5); + + // Assert + assertThat(product.getDescription()).isNull(); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Product.create(1L, null, "설명", 139000L, 100, 5) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Product.create(1L, " ", "설명", 139000L, 100, 5) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsZero() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Product.create(1L, "에어맥스 90", "설명", 0L, 100, 5) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsNegative() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Product.create(1L, "에어맥스 90", "설명", -1000L, 100, 5) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고 수량이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockQuantityIsNegative() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Product.create(1L, "에어맥스 90", "설명", 139000L, -1, 5) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고 수량이 0이면, 정상적으로 생성된다.") + @Test + void createsProduct_whenStockQuantityIsZero() { + // Arrange & Act + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 0, 5); + + // Assert + assertThat(product.getStockQuantity()).isZero(); + } + + @DisplayName("최대 주문 수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenMaxOrderQuantityIsZero() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 0) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("최대 주문 수량이 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenMaxOrderQuantityIsNegative() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + Product.create(1L, "에어맥스 90", "설명", 139000L, 100, -1) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상품을 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 정보로 수정하면, 정상적으로 수정된다.") + @Test + void updatesProduct_whenFieldsAreValid() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act + product.update("에어맥스 95", "업데이트된 설명", 149000L, 3); + + // Assert + assertAll( + () -> assertThat(product.getName()).isEqualTo("에어맥스 95"), + () -> assertThat(product.getDescription()).isEqualTo("업데이트된 설명"), + () -> assertThat(product.getPrice()).isEqualTo(149000L), + () -> assertThat(product.getMaxOrderQuantity()).isEqualTo(3), + () -> assertThat(product.getBrandId()).isEqualTo(1L) // 브랜드 변경 불가 + ); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + product.update(null, "설명", 149000L, 3) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsZero() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + product.update("에어맥스 95", "설명", 0L, 3) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("최대 주문 수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenMaxOrderQuantityIsZero() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + product.update("에어맥스 95", "설명", 149000L, 0) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고를 관리할 때, ") + @Nested + class Stock { + + @DisplayName("재고를 직접 설정하면(updateStock), 설정한 값으로 변경된다.") + @Test + void updatesStock_whenQuantityIsValid() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act + product.updateStock(200); + + // Assert + assertThat(product.getStockQuantity()).isEqualTo(200); + } + + @DisplayName("재고를 0으로 설정하면(updateStock), 정상적으로 변경된다.") + @Test + void updatesStock_whenQuantityIsZero() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act + product.updateStock(0); + + // Assert + assertThat(product.getStockQuantity()).isZero(); + } + + @DisplayName("재고를 음수로 설정하면(updateStock), BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUpdateStockIsNegative() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + product.updateStock(-1) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고를 차감하면(decreaseStock), 차감된 값으로 변경된다.") + @Test + void decreasesStock_whenSufficientQuantity() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act + product.decreaseStock(30); + + // Assert + assertThat(product.getStockQuantity()).isEqualTo(70); + } + + @DisplayName("재고와 동일한 수량을 차감하면(decreaseStock), 재고가 0이 된다.") + @Test + void decreasesStockToZero_whenQuantityEqualsStock() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act + product.decreaseStock(100); + + // Assert + assertThat(product.getStockQuantity()).isZero(); + } + + @DisplayName("재고보다 많은 수량을 차감하면(decreaseStock), BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenDecreaseStockExceedsAvailable() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + product.decreaseStock(101) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고를 증가시키면(increaseStock), 증가된 값으로 변경된다.") + @Test + void increasesStock_whenQuantityIsPositive() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act + product.increaseStock(50); + + // Assert + assertThat(product.getStockQuantity()).isEqualTo(150); + } + + @DisplayName("재고를 0으로 증가시키면(increaseStock), BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenIncreaseStockIsZero() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + product.increaseStock(0) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고를 음수로 증가시키면(increaseStock), BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenIncreaseStockIsNegative() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + product.increaseStock(-1) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문 수량을 검증할 때, ") + @Nested + class ValidateOrderQuantity { + + @DisplayName("최대 주문 수량 이하이면, 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenQuantityIsWithinLimit() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act & Assert (no exception) + product.validateOrderQuantity(5); + } + + @DisplayName("최대 주문 수량을 초과하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityExceedsMax() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, () -> + product.validateOrderQuantity(6) + ); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("좋아요 수를 관리할 때, ") + @Nested + class LikeCount { + + @DisplayName("좋아요 수를 증가시키면, 1 증가한다.") + @Test + void increasesLikeCount() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act + product.increaseLikeCount(); + + // Assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 수를 감소시키면, 1 감소한다.") + @Test + void decreasesLikeCount() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + product.increaseLikeCount(); + product.increaseLikeCount(); + + // Act + product.decreaseLikeCount(); + + // Assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 수가 0일 때 감소시키면, 0을 유지한다.") + @Test + void keepsZero_whenDecreasingFromZero() { + // Arrange + Product product = Product.create(1L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act + product.decreaseLikeCount(); + + // Assert + assertThat(product.getLikeCount()).isZero(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AddressV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AddressV1ApiE2ETest.java new file mode 100644 index 000000000..8bb7a5113 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AddressV1ApiE2ETest.java @@ -0,0 +1,444 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.address.AddressV1Dto; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AddressV1ApiE2ETest { + + private static final String ENDPOINT_MEMBERS = "/api/v1/members"; + private static final String ENDPOINT_ADDRESSES = "/api/v1/members/me/addresses"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AddressV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/members/me/addresses (배송지 등록)") + @Nested + class Register { + + @DisplayName("첫 배송지 등록 시, 201 Created와 isDefault=true를 반환한다.") + @Test + void returnsCreated_withDefaultTrue_whenFirstAddress() { + // arrange + registerMember("user1", "Test1234!"); + AddressV1Dto.CreateAddressRequest request = createAddressRequest("집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", "101호"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES, + HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().address().label()).isEqualTo("집"), + () -> assertThat(response.getBody().data().address().recipientName()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().address().isDefault()).isTrue() + ); + } + + @DisplayName("두 번째 배송지 등록 시, isDefault=false를 반환한다.") + @Test + void returnsCreated_withDefaultFalse_whenSecondAddress() { + // arrange + registerMember("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + registerAddress(headers, "집", "홍길동", "010-1234-5678", "12345", "서울시 강남구", "101호"); + + AddressV1Dto.CreateAddressRequest request = createAddressRequest("회사", "홍길동", "010-1234-5678", + "54321", "서울시 서초구", "202호"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().address().label()).isEqualTo("회사"), + () -> assertThat(response.getBody().data().address().isDefault()).isFalse() + ); + } + + @DisplayName("최대 10개 초과 등록 시, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenExceedsMaxCount() { + // arrange + registerMember("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + for (int i = 0; i < 10; i++) { + registerAddress(headers, "배송지" + i, "홍길동", "010-1234-5678", + "1234" + i, "주소" + i, null); + } + + AddressV1Dto.CreateAddressRequest request = createAddressRequest("11번째", "홍길동", "010-1234-5678", + "99999", "초과 주소", null); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("필수 필드가 누락되면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenRequiredFieldMissing() { + // arrange + registerMember("user1", "Test1234!"); + AddressV1Dto.CreateAddressRequest request = createAddressRequest(null, "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES, + HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("인증 없이 접근하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenNoAuth() { + // arrange + AddressV1Dto.CreateAddressRequest request = createAddressRequest("집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api/v1/members/me/addresses (배송지 목록 조회)") + @Nested + class GetAddresses { + + @DisplayName("본인의 배송지만 반환한다.") + @Test + void returnsOnlyOwnAddresses() { + // arrange - 두 명의 회원 각각 배송지 등록 + registerMember("user1", "Test1234!"); + registerMember("user2", "Test1234!"); + HttpHeaders headers1 = authHeaders("user1", "Test1234!"); + HttpHeaders headers2 = authHeaders("user2", "Test1234!"); + + registerAddress(headers1, "user1 집", "홍길동", "010-1111-1111", "11111", "주소1", null); + registerAddress(headers1, "user1 회사", "홍길동", "010-1111-1111", "11112", "주소2", null); + registerAddress(headers2, "user2 집", "김철수", "010-2222-2222", "22222", "주소3", null); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES, + HttpMethod.GET, + new HttpEntity<>(headers1), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().addresses()).hasSize(2), + () -> assertThat(response.getBody().data().addresses()) + .extracting(AddressV1Dto.AddressDto::label) + .containsExactlyInAnyOrder("user1 집", "user1 회사") + ); + } + } + + @DisplayName("PUT /api/v1/members/me/addresses/{addressId} (배송지 수정)") + @Nested + class Update { + + @DisplayName("유효한 요청으로 수정하면, 200 OK를 반환한다.") + @Test + void returnsOk_whenValidRequest() { + // arrange + registerMember("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + Long addressId = registerAddressAndGetId(headers, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", "101호"); + + AddressV1Dto.UpdateAddressRequest request = new AddressV1Dto.UpdateAddressRequest( + "새집", "홍길동", "010-9999-9999", "54321", "서울시 서초구", "202호" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES + "/" + addressId, + HttpMethod.PUT, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 수정 확인 + ResponseEntity> listResponse = testRestTemplate.exchange( + ENDPOINT_ADDRESSES, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + assertThat(listResponse.getBody().data().addresses().get(0).label()).isEqualTo("새집"); + } + + @DisplayName("존재하지 않는 배송지를 수정하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenAddressNotExists() { + // arrange + registerMember("user1", "Test1234!"); + AddressV1Dto.UpdateAddressRequest request = new AddressV1Dto.UpdateAddressRequest( + "새집", "홍길동", "010-9999-9999", "54321", "서울시 서초구", null + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES + "/9999", + HttpMethod.PUT, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("타인의 배송지를 수정하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenOtherMemberAddress() { + // arrange + registerMember("user1", "Test1234!"); + registerMember("user2", "Test1234!"); + Long addressId = registerAddressAndGetId(authHeaders("user1", "Test1234!"), + "집", "홍길동", "010-1234-5678", "12345", "서울시 강남구", null); + + AddressV1Dto.UpdateAddressRequest request = new AddressV1Dto.UpdateAddressRequest( + "탈취", "해커", "010-0000-0000", "00000", "해킹 주소", null + ); + + // act - user2가 user1의 배송지 수정 시도 + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES + "/" + addressId, + HttpMethod.PUT, + new HttpEntity<>(request, authHeaders("user2", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("DELETE /api/v1/members/me/addresses/{addressId} (배송지 삭제)") + @Nested + class Delete { + + @DisplayName("비기본 배송지를 삭제하면, 200 OK를 반환한다.") + @Test + void returnsOk_whenNonDefaultAddress() { + // arrange + registerMember("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + registerAddress(headers, "집", "홍길동", "010-1234-5678", "12345", "서울시 강남구", null); + Long secondId = registerAddressAndGetId(headers, "회사", "홍길동", "010-1234-5678", + "54321", "서울시 서초구", null); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES + "/" + secondId, + HttpMethod.DELETE, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 삭제 확인 + ResponseEntity> listResponse = testRestTemplate.exchange( + ENDPOINT_ADDRESSES, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + assertThat(listResponse.getBody().data().addresses()).hasSize(1); + } + + @DisplayName("기본 배송지를 삭제하면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenDefaultAddress() { + // arrange + registerMember("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + Long defaultId = registerAddressAndGetId(headers, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES + "/" + defaultId, + HttpMethod.DELETE, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("PATCH /api/v1/members/me/addresses/{addressId}/default (기본 배송지 설정)") + @Nested + class SetDefault { + + @DisplayName("기본 배송지를 변경하면, 200 OK를 반환하고 기존 기본이 해제된다.") + @Test + void returnsOk_andChangesDefault() { + // arrange + registerMember("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + Long firstId = registerAddressAndGetId(headers, "집", "홍길동", "010-1234-5678", + "12345", "서울시 강남구", null); + Long secondId = registerAddressAndGetId(headers, "회사", "홍길동", "010-1234-5678", + "54321", "서울시 서초구", null); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES + "/" + secondId + "/default", + HttpMethod.PATCH, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 기본 배송지 변경 확인 + ResponseEntity> listResponse = testRestTemplate.exchange( + ENDPOINT_ADDRESSES, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + List addresses = listResponse.getBody().data().addresses(); + AddressV1Dto.AddressDto first = addresses.stream().filter(a -> a.id().equals(firstId)).findFirst().get(); + AddressV1Dto.AddressDto second = addresses.stream().filter(a -> a.id().equals(secondId)).findFirst().get(); + + assertAll( + () -> assertThat(first.isDefault()).isFalse(), + () -> assertThat(second.isDefault()).isTrue() + ); + } + } + + // --- Helper Methods --- + + private void registerMember(String loginId, String password) { + MemberV1Dto.RegisterRequest request = new MemberV1Dto.RegisterRequest( + loginId, password, "홍길동", LocalDate.of(1990, 1, 15), + "MALE", loginId + "@example.com", null + ); + testRestTemplate.exchange( + ENDPOINT_MEMBERS, + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private void registerAddress(HttpHeaders headers, String label, String recipientName, + String recipientPhone, String zipCode, String address1, String address2) { + AddressV1Dto.CreateAddressRequest request = createAddressRequest( + label, recipientName, recipientPhone, zipCode, address1, address2); + testRestTemplate.exchange( + ENDPOINT_ADDRESSES, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference>() {} + ); + } + + private Long registerAddressAndGetId(HttpHeaders headers, String label, String recipientName, + String recipientPhone, String zipCode, String address1, String address2) { + AddressV1Dto.CreateAddressRequest request = createAddressRequest( + label, recipientName, recipientPhone, zipCode, address1, address2); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADDRESSES, + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().address().id(); + } + + private AddressV1Dto.CreateAddressRequest createAddressRequest(String label, String recipientName, + String recipientPhone, String zipCode, + String address1, String address2) { + return new AddressV1Dto.CreateAddressRequest(label, recipientName, recipientPhone, zipCode, address1, address2); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + return headers; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..47214a616 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java @@ -0,0 +1,315 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandV1ApiE2ETest { + + private static final String ENDPOINT_PUBLIC = "/api/v1/brands"; + private static final String ENDPOINT_ADMIN = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api-admin/v1/brands (브랜드 등록)") + @Nested + class Register { + + @DisplayName("유효한 정보로 등록하면, 201 Created 응답을 받는다.") + @Test + void returnsCreated_whenValidRequest() { + // Arrange + BrandV1Dto.RegisterRequest request = new BrandV1Dto.RegisterRequest("Nike", "Just Do It"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADMIN, + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().brand().name()).isEqualTo("Nike"), + () -> assertThat(response.getBody().data().brand().description()).isEqualTo("Just Do It"), + () -> assertThat(response.getBody().data().brand().likeCount()).isZero() + ); + } + + @DisplayName("이름이 빈 문자열이면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenNameIsBlank() { + // Arrange + BrandV1Dto.RegisterRequest request = new BrandV1Dto.RegisterRequest(" ", "설명"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADMIN, + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("동일 브랜드명으로 등록하면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenNameAlreadyExists() { + // Arrange + registerBrand("Nike", "Just Do It"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADMIN, + HttpMethod.POST, + adminEntity(new BrandV1Dto.RegisterRequest("Nike", "다른 설명")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/brands (브랜드 목록 조회)") + @Nested + class GetBrands { + + @DisplayName("브랜드를 등록한 후 목록 조회하면, 등록된 브랜드가 포함된다.") + @Test + void returnsBrands_whenBrandsExist() { + // Arrange + registerBrand("Nike", "Just Do It"); + registerBrand("Adidas", "Impossible Is Nothing"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PUBLIC, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().brands()).hasSize(2) + ); + } + + @DisplayName("키워드로 검색하면, 일치하는 브랜드만 반환한다.") + @Test + void returnsFilteredBrands_whenKeywordProvided() { + // Arrange + registerBrand("Nike", "Just Do It"); + registerBrand("Adidas", "Impossible Is Nothing"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PUBLIC + "?keyword=Nik", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().brands()).hasSize(1), + () -> assertThat(response.getBody().data().brands().get(0).name()).isEqualTo("Nike") + ); + } + } + + @DisplayName("GET /api/v1/brands/{brandId} (브랜드 상세 조회)") + @Nested + class GetBrand { + + @DisplayName("존재하는 브랜드를 조회하면, 200 OK 응답을 받는다.") + @Test + void returnsOk_whenBrandExists() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PUBLIC + "/" + brandId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().brand().name()).isEqualTo("Nike") + ); + } + + @DisplayName("존재하지 않는 브랜드를 조회하면, 404 Not Found 응답을 받는다.") + @Test + void returnsNotFound_whenBrandNotExists() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PUBLIC + "/999", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PUT /api-admin/v1/brands/{brandId} (브랜드 수정)") + @Nested + class Update { + + @DisplayName("유효한 정보로 수정하면, 200 OK 응답을 받는다.") + @Test + void returnsOk_whenValidRequest() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADMIN + "/" + brandId, + HttpMethod.PUT, + adminEntity(new BrandV1Dto.UpdateRequest("Adidas", "Impossible Is Nothing")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 수정 확인 + ResponseEntity> getResponse = testRestTemplate.exchange( + ENDPOINT_PUBLIC + "/" + brandId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + assertThat(getResponse.getBody().data().brand().name()).isEqualTo("Adidas"); + } + + @DisplayName("존재하지 않는 브랜드를 수정하면, 404 Not Found 응답을 받는다.") + @Test + void returnsNotFound_whenBrandNotExists() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADMIN + "/999", + HttpMethod.PUT, + adminEntity(new BrandV1Dto.UpdateRequest("Adidas", "설명")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{brandId} (브랜드 삭제)") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드를 삭제하면, 200 OK 응답을 받고 조회 시 404가 반환된다.") + @Test + void returnsOk_whenBrandExists() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADMIN + "/" + brandId, + HttpMethod.DELETE, + adminEntity(null), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 삭제 후 조회 시 NOT_FOUND + ResponseEntity> getResponse = testRestTemplate.exchange( + ENDPOINT_PUBLIC + "/" + brandId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("존재하지 않는 브랜드를 삭제하면, 404 Not Found 응답을 받는다.") + @Test + void returnsNotFound_whenBrandNotExists() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADMIN + "/999", + HttpMethod.DELETE, + adminEntity(null), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpEntity adminEntity(T body) { + return new HttpEntity<>(body, adminHeaders()); + } + + private Long registerBrand(String name, String description) { + BrandV1Dto.RegisterRequest request = new BrandV1Dto.RegisterRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ADMIN, + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().brand().id(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..c0dcbd224 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java @@ -0,0 +1,432 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.interfaces.api.like.LikeV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.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 LikeV1ApiE2ETest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public LikeV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/products/{productId}/likes (상품 좋아요 토글)") + @Nested + class ToggleProductLike { + + @DisplayName("첫 좋아요 시, liked=true와 likeCount=1을 반환한다.") + @Test + void returnsLikedTrue_whenFirstLike() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().liked()).isTrue(), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(1) + ); + } + + @DisplayName("다시 토글하면, liked=false와 likeCount=0을 반환한다.") + @Test + void returnsLikedFalse_whenToggleAgain() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + + // 첫 좋아요 + testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() {} + ); + + // Act - 두 번째 토글 + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().liked()).isFalse(), + () -> assertThat(response.getBody().data().likeCount()).isZero() + ); + } + + @DisplayName("좋아요 후 상품 상세 조회에서 likeCount가 반영된다.") + @Test + void reflectsLikeCount_inProductDetail() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + + // Act - 좋아요 + testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference>() {} + ); + + // Assert - 상품 상세에서 likeCount 확인 + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + assertThat(productResponse.getBody().data().product().likeCount()).isEqualTo(1); + } + + @DisplayName("존재하지 않는 상품에 좋아요하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenProductNotExists() { + // Arrange + registerMember("user1", "Test1234!"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/products/9999/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("인증 없이 접근하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenNoAuth() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/products/1/likes", + HttpMethod.POST, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("POST /api/v1/brands/{brandId}/likes (브랜드 좋아요 토글)") + @Nested + class ToggleBrandLike { + + @DisplayName("첫 좋아요 시, liked=true와 likeCount=1을 반환한다.") + @Test + void returnsLikedTrue_whenFirstLike() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/brands/" + brandId + "/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().liked()).isTrue(), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(1) + ); + } + + @DisplayName("다시 토글하면, liked=false와 likeCount=0을 반환한다.") + @Test + void returnsLikedFalse_whenToggleAgain() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + + testRestTemplate.exchange( + "/api/v1/brands/" + brandId + "/likes", + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() {} + ); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/brands/" + brandId + "/likes", + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().liked()).isFalse(), + () -> assertThat(response.getBody().data().likeCount()).isZero() + ); + } + + @DisplayName("인증 없이 접근하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenNoAuth() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/brands/1/likes", + HttpMethod.POST, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api/v1/members/me/likes/products (내 상품 좋아요 목록)") + @Nested + class GetMyLikedProducts { + + @DisplayName("좋아요한 상품만 반환한다.") + @Test + void returnsOnlyLikedProducts() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long product1 = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long product2 = registerProduct(brandId, "에어포스 1", 119000L, 50, 3); + registerProduct(brandId, "덩크 로우", 159000L, 80, 4); // 좋아요 안 함 + + HttpHeaders headers = authHeaders("user1", "Test1234!"); + toggleProductLike(headers, product1); + toggleProductLike(headers, product2); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me/likes/products", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().products()).hasSize(2) + ); + } + + @DisplayName("좋아요한 상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoLikes() { + // Arrange + registerMember("user1", "Test1234!"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me/likes/products", + HttpMethod.GET, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().products()).isEmpty() + ); + } + + @DisplayName("인증 없이 접근하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenNoAuth() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me/likes/products", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api/v1/members/me/likes/brands (내 브랜드 좋아요 목록)") + @Nested + class GetMyLikedBrands { + + @DisplayName("좋아요한 브랜드만 반환한다.") + @Test + void returnsOnlyLikedBrands() { + // Arrange + registerMember("user1", "Test1234!"); + Long brand1 = registerBrand("Nike", "Just Do It"); + Long brand2 = registerBrand("Adidas", "Impossible Is Nothing"); + registerBrand("Puma", "Forever Faster"); // 좋아요 안 함 + + HttpHeaders headers = authHeaders("user1", "Test1234!"); + toggleBrandLike(headers, brand1); + toggleBrandLike(headers, brand2); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me/likes/brands", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().brands()).hasSize(2) + ); + } + + @DisplayName("인증 없이 접근하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenNoAuth() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me/likes/brands", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + // --- Helper Methods --- + + private void registerMember(String loginId, String password) { + MemberV1Dto.RegisterRequest request = new MemberV1Dto.RegisterRequest( + loginId, password, "홍길동", LocalDate.of(1990, 1, 15), + "MALE", loginId + "@example.com", null + ); + testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private Long registerBrand(String name, String description) { + var request = new BrandV1Dto.RegisterRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/brands", + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().brand().id(); + } + + private Long registerProduct(Long brandId, String name, Long price, int stock, int maxOrder) { + var request = new ProductV1Dto.RegisterRequest(brandId, name, "설명", price, stock, maxOrder); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/products", + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().product().id(); + } + + private void toggleProductLike(HttpHeaders headers, Long productId) { + testRestTemplate.exchange( + "/api/v1/products/" + productId + "/likes", + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() {} + ); + } + + private void toggleBrandLike(HttpHeaders headers, Long brandId) { + testRestTemplate.exchange( + "/api/v1/brands/" + brandId + "/likes", + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() {} + ); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + return headers; + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpEntity adminEntity(T body) { + return new HttpEntity<>(body, adminHeaders()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberAdminV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberAdminV1ApiE2ETest.java new file mode 100644 index 000000000..58954aa21 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/MemberAdminV1ApiE2ETest.java @@ -0,0 +1,312 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.member.MemberAdminV1Dto; +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 MemberAdminV1ApiE2ETest { + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public MemberAdminV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api-admin/v1/members (회원 목록 조회)") + @Nested + class GetMembers { + + @DisplayName("전체 회원 목록을 조회한다.") + @Test + void returnsAllMembers() { + // Arrange + registerMember("user1", "홍길동", "user1@example.com"); + registerMember("user2", "김영희", "user2@example.com"); + registerMember("user3", "이철수", "user3@example.com"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/members?page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().members()).hasSize(3), + () -> assertThat(response.getBody().data().page().totalElements()).isEqualTo(3) + ); + } + + @DisplayName("keyword로 loginId를 검색한다.") + @Test + void searchesByLoginId() { + // Arrange + registerMember("admin01", "홍길동", "admin01@example.com"); + registerMember("user01", "김영희", "user01@example.com"); + registerMember("user02", "이철수", "user02@example.com"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/members?keyword=admin&page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().members()).hasSize(1), + () -> assertThat(response.getBody().data().members().get(0).loginId()).isEqualTo("admin01") + ); + } + + @DisplayName("keyword로 이름을 검색한다.") + @Test + void searchesByName() { + // Arrange + registerMember("user1", "홍길동", "user1@example.com"); + registerMember("user2", "김영희", "user2@example.com"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/members?keyword=홍길동&page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().members()).hasSize(1), + () -> assertThat(response.getBody().data().members().get(0).name()).isEqualTo("홍길동") + ); + } + + @DisplayName("keyword로 이메일을 검색한다.") + @Test + void searchesByEmail() { + // Arrange + registerMember("user1", "홍길동", "hong@loopers.com"); + registerMember("user2", "김영희", "kim@example.com"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/members?keyword=loopers&page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().members()).hasSize(1), + () -> assertThat(response.getBody().data().members().get(0).loginId()).isEqualTo("user1") + ); + } + + @DisplayName("keyword에 매칭되는 회원이 없으면 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoMatch() { + // Arrange + registerMember("user1", "홍길동", "user1@example.com"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/members?keyword=nonexistent&page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().members()).isEmpty(), + () -> assertThat(response.getBody().data().page().totalElements()).isEqualTo(0) + ); + } + + @DisplayName("페이징이 정상 동작한다.") + @Test + void returnsPaginatedResults() { + // Arrange + registerMember("user1", "홍길동", "user1@example.com"); + registerMember("user2", "김영희", "user2@example.com"); + registerMember("user3", "이철수", "user3@example.com"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/members?page=0&size=2", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().members()).hasSize(2), + () -> assertThat(response.getBody().data().page().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().page().totalPages()).isEqualTo(2) + ); + } + + @DisplayName("Admin 인증 없이 요청하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_withoutAdminAuth() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/members?page=0&size=20", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api-admin/v1/members/{memberId} (회원 상세 조회)") + @Nested + class GetMember { + + @DisplayName("회원 상세 정보를 조회한다 (마스킹 없음).") + @Test + void returnsMemberDetail_withoutMasking() { + // Arrange + registerMember("user1", "홍길동", "user1@example.com"); + Long memberId = getMemberIdByKeyword("user1"); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/members/" + memberId, + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().member().id()).isEqualTo(memberId), + () -> assertThat(response.getBody().data().member().loginId()).isEqualTo("user1"), + () -> assertThat(response.getBody().data().member().name()).isEqualTo("홍길동"), + () -> assertThat(response.getBody().data().member().email()).isEqualTo("user1@example.com"), + () -> assertThat(response.getBody().data().member().gender()).isEqualTo("MALE"), + () -> assertThat(response.getBody().data().member().createdAt()).isNotNull() + ); + } + + @DisplayName("존재하지 않는 회원을 조회하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenMemberNotExists() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/members/999999", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("탈퇴한 회원을 조회하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenMemberWithdrawn() { + // Arrange + registerMember("user1", "홍길동", "user1@example.com"); + Long memberId = getMemberIdByKeyword("user1"); + + // 회원 탈퇴 + var withdrawRequest = new MemberV1Dto.WithdrawRequest("Test1234!"); + HttpHeaders authHeaders = new HttpHeaders(); + authHeaders.set("X-Loopers-LoginId", "user1"); + authHeaders.set("X-Loopers-LoginPw", "Test1234!"); + testRestTemplate.exchange( + "/api/v1/members/me", + HttpMethod.DELETE, + new HttpEntity<>(withdrawRequest, authHeaders), + new ParameterizedTypeReference>() {} + ); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/members/" + memberId, + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + // --- Helper Methods --- + + private void registerMember(String loginId, String name, String email) { + var request = new MemberV1Dto.RegisterRequest( + loginId, "Test1234!", name, LocalDate.of(1990, 1, 15), + "MALE", email, "010-1234-5678" + ); + testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private Long getMemberIdByKeyword(String keyword) { + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/members?keyword=" + keyword + "&page=0&size=1", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().members().get(0).id(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } +} 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 index 6e38b464b..3d4db81a2 100644 --- 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 @@ -64,7 +64,9 @@ void returnsCreated_whenValidRequest() { "Test1234!", "홍길동", LocalDate.of(1990, 1, 15), - "test@example.com" + "MALE", + "test@example.com", + "010-1234-5678" ); // act @@ -80,6 +82,8 @@ void returnsCreated_whenValidRequest() { assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), () -> assertThat(response.getBody().data().member().loginId()).isEqualTo("testUser1"), + () -> assertThat(response.getBody().data().member().gender()).isEqualTo("MALE"), + () -> assertThat(response.getBody().data().member().phone()).isEqualTo("010-1234-5678"), () -> assertThat(memberJpaRepository.existsByLoginId("testUser1")).isTrue() ); } @@ -88,19 +92,7 @@ void returnsCreated_whenValidRequest() { @Test void returnsBadRequest_whenLoginIdAlreadyExists() { // arrange - 먼저 회원가입 - MemberV1Dto.RegisterRequest firstRequest = new MemberV1Dto.RegisterRequest( - "existingUser", - "Test1234!", - "홍길동", - LocalDate.of(1990, 1, 15), - "first@example.com" - ); - testRestTemplate.exchange( - ENDPOINT_REGISTER, - HttpMethod.POST, - new HttpEntity<>(firstRequest), - new ParameterizedTypeReference>() {} - ); + registerMember("existingUser", "Test1234!"); // arrange - 같은 로그인ID로 다시 가입 시도 MemberV1Dto.RegisterRequest duplicateRequest = new MemberV1Dto.RegisterRequest( @@ -108,16 +100,17 @@ void returnsBadRequest_whenLoginIdAlreadyExists() { "Test5678!", "김철수", LocalDate.of(1985, 5, 20), - "second@example.com" + "MALE", + "second@example.com", + null ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(duplicateRequest), - responseType + new ParameterizedTypeReference<>() {} ); // assert @@ -133,16 +126,17 @@ void returnsBadRequest_whenInvalidEmail() { "Test1234!", "홍길동", LocalDate.of(1990, 1, 15), - "invalid-email" + "MALE", + "invalid-email", + null ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(request), - responseType + new ParameterizedTypeReference<>() {} ); // assert @@ -160,19 +154,7 @@ void returnsOk_whenValidAuth() { // arrange - 먼저 회원가입 String loginId = "testUser1"; String password = "Test1234!"; - MemberV1Dto.RegisterRequest registerRequest = new MemberV1Dto.RegisterRequest( - loginId, - password, - "홍길동", - LocalDate.of(1990, 1, 15), - "test@example.com" - ); - testRestTemplate.exchange( - ENDPOINT_REGISTER, - HttpMethod.POST, - new HttpEntity<>(registerRequest), - new ParameterizedTypeReference>() {} - ); + registerMember(loginId, password); // arrange - 인증 헤더 설정 HttpHeaders headers = new HttpHeaders(); @@ -180,20 +162,20 @@ void returnsOk_whenValidAuth() { headers.set(HEADER_LOGIN_PW, password); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), - responseType + new ParameterizedTypeReference<>() {} ); // assert assertAll( () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), () -> assertThat(response.getBody().data().member().loginId()).isEqualTo(loginId), - () -> assertThat(response.getBody().data().member().name()).isEqualTo("홍길*"), // 마스킹 확인 - () -> assertThat(response.getBody().data().member().email()).isEqualTo("test@example.com") + () -> assertThat(response.getBody().data().member().name()).isEqualTo("홍길*"), + () -> assertThat(response.getBody().data().member().gender()).isEqualTo("MALE"), + () -> assertThat(response.getBody().data().member().email()).isEqualTo(loginId + "@example.com") ); } @@ -201,12 +183,11 @@ void returnsOk_whenValidAuth() { @Test void returnsUnauthorized_whenNoAuthHeader() { // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(null), - responseType + new ParameterizedTypeReference<>() {} ); // assert @@ -223,19 +204,7 @@ void returnsUnauthorized_whenNoAuthHeader() { void returnsUnauthorized_whenWrongPassword() { // arrange - 먼저 회원가입 String loginId = "testUser2"; - MemberV1Dto.RegisterRequest registerRequest = new MemberV1Dto.RegisterRequest( - loginId, - "Test1234!", - "홍길동", - LocalDate.of(1990, 1, 15), - "test2@example.com" - ); - testRestTemplate.exchange( - ENDPOINT_REGISTER, - HttpMethod.POST, - new HttpEntity<>(registerRequest), - new ParameterizedTypeReference>() {} - ); + registerMember(loginId, "Test1234!"); // arrange - 잘못된 비밀번호로 인증 헤더 설정 HttpHeaders headers = new HttpHeaders(); @@ -243,12 +212,11 @@ void returnsUnauthorized_whenWrongPassword() { headers.set(HEADER_LOGIN_PW, "WrongPassword1!"); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_ME, HttpMethod.GET, new HttpEntity<>(headers), - responseType + new ParameterizedTypeReference<>() {} ); // assert @@ -312,18 +280,17 @@ void returnsOk_whenValidRequest() { @DisplayName("현재 비밀번호가 일치하지 않으면, 400 Bad Request 응답을 받는다.") @Test void returnsBadRequest_whenCurrentPasswordNotMatch() { - // arrange - 먼저 회원가입 + // arrange String loginId = "testUser2"; String currentPassword = "Test1234!"; registerMember(loginId, currentPassword); - // arrange - 인증 헤더와 잘못된 현재 비밀번호로 요청 HttpHeaders headers = new HttpHeaders(); headers.set(HEADER_LOGIN_ID, loginId); headers.set(HEADER_LOGIN_PW, currentPassword); MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( - "WrongCurrent1!", // 잘못된 현재 비밀번호 + "WrongCurrent1!", "NewPass5678!" ); @@ -342,19 +309,18 @@ void returnsBadRequest_whenCurrentPasswordNotMatch() { @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면, 400 Bad Request 응답을 받는다.") @Test void returnsBadRequest_whenNewPasswordSameAsCurrent() { - // arrange - 먼저 회원가입 + // arrange String loginId = "testUser3"; String currentPassword = "Test1234!"; registerMember(loginId, currentPassword); - // arrange - 인증 헤더와 동일한 비밀번호로 요청 HttpHeaders headers = new HttpHeaders(); headers.set(HEADER_LOGIN_ID, loginId); headers.set(HEADER_LOGIN_PW, currentPassword); MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( currentPassword, - currentPassword // 현재 비밀번호와 동일 + currentPassword ); // act @@ -372,19 +338,18 @@ void returnsBadRequest_whenNewPasswordSameAsCurrent() { @DisplayName("새 비밀번호가 규칙을 위반하면, 400 Bad Request 응답을 받는다.") @Test void returnsBadRequest_whenNewPasswordInvalid() { - // arrange - 먼저 회원가입 + // arrange String loginId = "testUser4"; String currentPassword = "Test1234!"; registerMember(loginId, currentPassword); - // arrange - 인증 헤더와 규칙 위반 비밀번호로 요청 HttpHeaders headers = new HttpHeaders(); headers.set(HEADER_LOGIN_ID, loginId); headers.set(HEADER_LOGIN_PW, currentPassword); MemberV1Dto.ChangePasswordRequest request = new MemberV1Dto.ChangePasswordRequest( currentPassword, - "short" // 8자 미만 + "short" ); // act @@ -424,22 +389,177 @@ void returnsUnauthorized_whenNoAuthHeader() { () -> assertThat(response.getBody().meta().errorCode()).isEqualTo("Unauthorized") ); } + } - private void registerMember(String loginId, String password) { - MemberV1Dto.RegisterRequest registerRequest = new MemberV1Dto.RegisterRequest( - loginId, - password, - "홍길동", - LocalDate.of(1990, 1, 15), - loginId + "@example.com" + @DisplayName("PATCH /api/v1/members/me (내 정보 수정)") + @Nested + class UpdatePhone { + + @DisplayName("유효한 전화번호로 수정하면, 200 OK 응답을 받는다.") + @Test + void returnsOk_whenValidPhone() { + // arrange + String loginId = "testUser1"; + String password = "Test1234!"; + registerMember(loginId, password); + + MemberV1Dto.UpdatePhoneRequest request = new MemberV1Dto.UpdatePhoneRequest("010-9999-8888"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.PATCH, + new HttpEntity<>(request, authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 수정 확인 + ResponseEntity> meResponse = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} ); + assertThat(meResponse.getBody().data().member().phone()).isEqualTo("010-9999-8888"); + } + + @DisplayName("잘못된 전화번호 형식이면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenInvalidPhone() { + // arrange + String loginId = "testUser2"; + String password = "Test1234!"; + registerMember(loginId, password); + + MemberV1Dto.UpdatePhoneRequest request = new MemberV1Dto.UpdatePhoneRequest("invalid"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.PATCH, + new HttpEntity<>(request, authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("DELETE /api/v1/members/me (회원 탈퇴)") + @Nested + class Withdraw { + + @DisplayName("비밀번호가 일치하면, 200 OK 응답을 받고 이후 로그인이 차단된다.") + @Test + void returnsOk_andBlocksLogin_whenPasswordMatches() { + // arrange + String loginId = "testUser1"; + String password = "Test1234!"; + registerMember(loginId, password); + + MemberV1Dto.WithdrawRequest request = new MemberV1Dto.WithdrawRequest(password); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.DELETE, + new HttpEntity<>(request, authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + + // assert - 탈퇴 성공 + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // assert - 탈퇴 후 로그인 시도 시 UNAUTHORIZED + ResponseEntity> meResponse = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.GET, + new HttpEntity<>(authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("비밀번호가 일치하지 않으면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenPasswordDoesNotMatch() { + // arrange + String loginId = "testUser2"; + String password = "Test1234!"; + registerMember(loginId, password); + + MemberV1Dto.WithdrawRequest request = new MemberV1Dto.WithdrawRequest("WrongPass1!"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.DELETE, + new HttpEntity<>(request, authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("탈퇴 후 동일 loginId로 재가입하면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenReRegisterWithSameLoginId() { + // arrange - 가입 후 탈퇴 + String loginId = "testUser3"; + String password = "Test1234!"; + registerMember(loginId, password); + testRestTemplate.exchange( + ENDPOINT_ME, + HttpMethod.DELETE, + new HttpEntity<>(new MemberV1Dto.WithdrawRequest(password), authHeaders(loginId, password)), + new ParameterizedTypeReference>() {} + ); + + // act - 동일 loginId로 재가입 + MemberV1Dto.RegisterRequest reRegisterRequest = new MemberV1Dto.RegisterRequest( + loginId, "NewPass5678!", "김철수", + LocalDate.of(1995, 3, 10), "FEMALE", "new@example.com", null + ); + ResponseEntity> response = testRestTemplate.exchange( ENDPOINT_REGISTER, HttpMethod.POST, - new HttpEntity<>(registerRequest), - new ParameterizedTypeReference>() {} + new HttpEntity<>(reRegisterRequest), + new ParameterizedTypeReference<>() {} ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } } + private void registerMember(String loginId, String password) { + MemberV1Dto.RegisterRequest registerRequest = new MemberV1Dto.RegisterRequest( + loginId, + password, + "홍길동", + LocalDate.of(1990, 1, 15), + "MALE", + loginId + "@example.com", + null + ); + testRestTemplate.exchange( + ENDPOINT_REGISTER, + HttpMethod.POST, + new HttpEntity<>(registerRequest), + new ParameterizedTypeReference>() {} + ); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + return headers; + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..6670df11b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -0,0 +1,796 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.address.AddressV1Dto; +import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.interfaces.api.member.MemberV1Dto; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public OrderV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/orders (주문 생성)") + @Nested + class CreateOrder { + + @DisplayName("단일 상품 주문 시, 주문 정보와 재고 차감을 확인한다.") + @Test + void createsOrder_withSingleItem() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 2)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().order().totalAmount()).isEqualTo(278000L), + () -> assertThat(response.getBody().data().order().status()).isEqualTo("COMPLETED"), + () -> assertThat(response.getBody().data().order().items()).hasSize(1), + () -> assertThat(response.getBody().data().order().items().get(0).quantity()).isEqualTo(2), + () -> assertThat(response.getBody().data().order().recipientName()).isEqualTo("홍길동") + ); + + // 재고 차감 확인 + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + assertThat(productResponse.getBody().data().product().stockQuantity()).isEqualTo(98); + } + + @DisplayName("다수 상품 주문 시, 각 상품의 주문 항목이 생성된다.") + @Test + void createsOrder_withMultipleItems() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long product1 = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long product2 = registerProduct(brandId, "에어포스 1", 119000L, 50, 3); + Long addressId = registerAddress("user1", "Test1234!"); + + var items = List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(product1, 1), + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(product2, 2) + ); + var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().order().totalAmount()).isEqualTo(377000L), + () -> assertThat(response.getBody().data().order().items()).hasSize(2) + ); + } + + @DisplayName("동일 상품 중복 요청 시, 수량이 합산된다.") + @Test + void mergesDuplicateItems() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + + var items = List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 2), + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 3) + ); + var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().order().items()).hasSize(1), + () -> assertThat(response.getBody().data().order().items().get(0).quantity()).isEqualTo(5), + () -> assertThat(response.getBody().data().order().totalAmount()).isEqualTo(695000L) + ); + } + + @DisplayName("재고 부족 시, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenInsufficientStock() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 3, 5); + Long addressId = registerAddress("user1", "Test1234!"); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 4)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + + // 재고 롤백 확인 + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + assertThat(productResponse.getBody().data().product().stockQuantity()).isEqualTo(3); + } + + @DisplayName("maxOrderQuantity 초과 시, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenExceedsMaxOrderQuantity() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 3); + Long addressId = registerAddress("user1", "Test1234!"); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 4)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 배송지로 주문 시, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenAddressNotExists() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1)); + var request = new OrderV1Dto.CreateOrderRequest(9999L, items); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("빈 items로 주문 시, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenEmptyItems() { + // Arrange + registerMember("user1", "Test1234!"); + Long addressId = registerAddress("user1", "Test1234!"); + + var request = new OrderV1Dto.CreateOrderRequest(addressId, List.of()); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("quantity가 0 이하이면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenQuantityZeroOrNegative() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + + var items = List.of(new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 0)); + var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("인증 없이 접근하면, 401 Unauthorized를 반환한다.") + @Test + void returnsUnauthorized_whenNoAuth() { + // Act + var request = new OrderV1Dto.CreateOrderRequest(1L, List.of()); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api/v1/orders (내 주문 목록)") + @Nested + class GetMyOrders { + + @DisplayName("날짜 범위 내 주문만 반환한다.") + @Test + void returnsOrdersWithinDateRange() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + + createOrder(headers, addressId, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1) + )); + createOrder(headers, addressId, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 2) + )); + + LocalDate today = LocalDate.now(); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders?startAt=" + today + "&endAt=" + today + "&page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().orders()).hasSize(2) + ); + } + + @DisplayName("타인의 주문은 조회되지 않는다.") + @Test + void excludesOtherMemberOrders() { + // Arrange + registerMember("user1", "Test1234!"); + registerMember("user2", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long address1 = registerAddress("user1", "Test1234!"); + Long address2 = registerAddress("user2", "Test1234!"); + + createOrder(authHeaders("user1", "Test1234!"), address1, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1) + )); + createOrder(authHeaders("user2", "Test1234!"), address2, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1) + )); + + LocalDate today = LocalDate.now(); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders?startAt=" + today + "&endAt=" + today + "&page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(authHeaders("user1", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getBody().data().orders()).hasSize(1); + } + } + + @DisplayName("GET /api/v1/orders/{orderId} (주문 상세)") + @Nested + class GetOrder { + + @DisplayName("본인 주문 상세를 조회한다.") + @Test + void returnsOrderDetail() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + + Long orderId = createOrderAndGetId(headers, addressId, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 2) + )); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders/" + orderId, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().order().id()).isEqualTo(orderId), + () -> assertThat(response.getBody().data().order().totalAmount()).isEqualTo(278000L), + () -> assertThat(response.getBody().data().order().items()).hasSize(1) + ); + } + + @DisplayName("타인의 주문을 조회하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenOtherMemberOrder() { + // Arrange + registerMember("user1", "Test1234!"); + registerMember("user2", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + + Long orderId = createOrderAndGetId(authHeaders("user1", "Test1234!"), addressId, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1) + )); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders/" + orderId, + HttpMethod.GET, + new HttpEntity<>(authHeaders("user2", "Test1234!")), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("POST /api/v1/orders/{orderId}/cancel (주문 취소)") + @Nested + class CancelOrder { + + @DisplayName("주문 취소 시, 상태가 CANCELLED로 변경되고 재고가 복원된다.") + @Test + void cancelsOrder_andRestoresStock() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + + Long orderId = createOrderAndGetId(headers, addressId, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 3) + )); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/cancel", + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 상태 확인 + ResponseEntity> orderResponse = testRestTemplate.exchange( + "/api/v1/orders/" + orderId, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + assertThat(orderResponse.getBody().data().order().status()).isEqualTo("CANCELLED"); + + // 재고 복원 확인 + ResponseEntity> productResponse = testRestTemplate.exchange( + "/api/v1/products/" + productId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + assertThat(productResponse.getBody().data().product().stockQuantity()).isEqualTo(100); + } + + @DisplayName("이미 취소된 주문을 취소하면, 404 Not Found를 반환한다.") + @Test + void returnsNotFound_whenAlreadyCancelled() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + + Long orderId = createOrderAndGetId(headers, addressId, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1) + )); + + // 첫 번째 취소 + testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/cancel", + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() {} + ); + + // Act - 두 번째 취소 + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/cancel", + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PUT /api/v1/orders/{orderId}/shipping-address (배송지 수정)") + @Nested + class UpdateShippingAddress { + + @DisplayName("정상적으로 배송지를 수정한다.") + @Test + void updatesShippingAddress() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + + Long orderId = createOrderAndGetId(headers, addressId, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1) + )); + + var updateRequest = new OrderV1Dto.UpdateShippingAddressRequest( + "김철수", "010-9999-9999", "54321", "서울시 서초구", "202호" + ); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/shipping-address", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().order().recipientName()).isEqualTo("김철수"), + () -> assertThat(response.getBody().data().order().recipientPhone()).isEqualTo("010-9999-9999"), + () -> assertThat(response.getBody().data().order().zipCode()).isEqualTo("54321") + ); + } + + @DisplayName("취소된 주문의 배송지를 수정하면, 400 Bad Request를 반환한다.") + @Test + void returnsBadRequest_whenOrderCancelled() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + HttpHeaders headers = authHeaders("user1", "Test1234!"); + + Long orderId = createOrderAndGetId(headers, addressId, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1) + )); + + // 주문 취소 + testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/cancel", + HttpMethod.POST, + new HttpEntity<>(headers), + new ParameterizedTypeReference>() {} + ); + + var updateRequest = new OrderV1Dto.UpdateShippingAddressRequest( + "김철수", "010-9999-9999", "54321", "서울시 서초구", "202호" + ); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders/" + orderId + "/shipping-address", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("Admin API") + @Nested + class AdminApi { + + @DisplayName("GET /api-admin/v1/orders - 전체 주문 목록을 조회한다.") + @Test + void returnsAllOrders() { + // Arrange + registerMember("user1", "Test1234!"); + registerMember("user2", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long address1 = registerAddress("user1", "Test1234!"); + Long address2 = registerAddress("user2", "Test1234!"); + + createOrder(authHeaders("user1", "Test1234!"), address1, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1) + )); + createOrder(authHeaders("user2", "Test1234!"), address2, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1) + )); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/orders?page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().orders()).hasSize(2) + ); + } + + @DisplayName("GET /api-admin/v1/orders?memberId= - memberId로 필터링한다.") + @Test + void filtersOrdersByMemberId() { + // Arrange + registerMember("user1", "Test1234!"); + registerMember("user2", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long address1 = registerAddress("user1", "Test1234!"); + Long address2 = registerAddress("user2", "Test1234!"); + + Long orderId = createOrderAndGetId(authHeaders("user1", "Test1234!"), address1, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1) + )); + createOrder(authHeaders("user2", "Test1234!"), address2, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 1) + )); + + // user1의 memberId 추출 + Long memberId = getOrderMemberId(orderId); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/orders?memberId=" + memberId + "&page=0&size=20", + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().orders()).hasSize(1) + ); + } + + @DisplayName("GET /api-admin/v1/orders/{orderId} - 주문 상세를 조회한다.") + @Test + void returnsOrderDetail() { + // Arrange + registerMember("user1", "Test1234!"); + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long addressId = registerAddress("user1", "Test1234!"); + + Long orderId = createOrderAndGetId(authHeaders("user1", "Test1234!"), addressId, List.of( + new OrderV1Dto.CreateOrderRequest.OrderItemRequest(productId, 2) + )); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/orders/" + orderId, + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().order().id()).isEqualTo(orderId), + () -> assertThat(response.getBody().data().order().totalAmount()).isEqualTo(278000L) + ); + } + } + + // --- Helper Methods --- + + private void registerMember(String loginId, String password) { + var request = new MemberV1Dto.RegisterRequest( + loginId, password, "홍길동", LocalDate.of(1990, 1, 15), + "MALE", loginId + "@example.com", null + ); + testRestTemplate.exchange( + "/api/v1/members", + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference>() {} + ); + } + + private Long registerBrand(String name, String description) { + var request = new BrandV1Dto.RegisterRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/brands", + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().brand().id(); + } + + private Long registerProduct(Long brandId, String name, Long price, int stock, int maxOrder) { + var request = new ProductV1Dto.RegisterRequest(brandId, name, "설명", price, stock, maxOrder); + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/products", + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().product().id(); + } + + private Long registerAddress(String loginId, String password) { + var request = new AddressV1Dto.CreateAddressRequest( + "집", "홍길동", "010-1234-5678", "12345", "서울시 강남구", "101호" + ); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/members/me/addresses", + HttpMethod.POST, + new HttpEntity<>(request, authHeaders(loginId, password)), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().address().id(); + } + + private void createOrder(HttpHeaders headers, Long addressId, + List items) { + var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference>() {} + ); + } + + private Long createOrderAndGetId(HttpHeaders headers, Long addressId, + List items) { + var request = new OrderV1Dto.CreateOrderRequest(addressId, items); + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().order().id(); + } + + private Long getOrderMemberId(Long orderId) { + ResponseEntity> response = testRestTemplate.exchange( + "/api-admin/v1/orders/" + orderId, + HttpMethod.GET, + new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().order().memberId(); + } + + private HttpHeaders authHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, loginId); + headers.set(HEADER_LOGIN_PW, password); + return headers; + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpEntity adminEntity(T body) { + return new HttpEntity<>(body, adminHeaders()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..49bd65f12 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -0,0 +1,568 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductV1ApiE2ETest { + + private static final String PRODUCT_PUBLIC = "/api/v1/products"; + private static final String PRODUCT_ADMIN = "/api-admin/v1/products"; + private static final String BRAND_ADMIN = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api-admin/v1/products (상품 등록)") + @Nested + class Register { + + @DisplayName("유효한 정보로 등록하면, 201 Created 응답과 브랜드 정보를 받는다.") + @Test + void returnsCreated_whenValidRequest() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + var request = new ProductV1Dto.RegisterRequest(brandId, "에어맥스 90", "클래식 운동화", 139000L, 100, 5); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN, + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody().data().product().name()).isEqualTo("에어맥스 90"), + () -> assertThat(response.getBody().data().product().price()).isEqualTo(139000L), + () -> assertThat(response.getBody().data().product().stockQuantity()).isEqualTo(100), + () -> assertThat(response.getBody().data().product().maxOrderQuantity()).isEqualTo(5), + () -> assertThat(response.getBody().data().product().likeCount()).isZero(), + () -> assertThat(response.getBody().data().product().brand().name()).isEqualTo("Nike") + ); + } + + @DisplayName("존재하지 않는 브랜드로 등록하면, 404 Not Found 응답을 받는다.") + @Test + void returnsNotFound_whenBrandNotExists() { + // Arrange + var request = new ProductV1Dto.RegisterRequest(999L, "에어맥스 90", "설명", 139000L, 100, 5); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN, + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("이름이 빈 문자열이면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenNameIsBlank() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + var request = new ProductV1Dto.RegisterRequest(brandId, " ", "설명", 139000L, 100, 5); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN, + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("가격이 0이면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenPriceIsZero() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + var request = new ProductV1Dto.RegisterRequest(brandId, "에어맥스 90", "설명", 0L, 100, 5); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN, + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("최대 주문 수량이 0이면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenMaxOrderQuantityIsZero() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + var request = new ProductV1Dto.RegisterRequest(brandId, "에어맥스 90", "설명", 139000L, 100, 0); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN, + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/products (상품 목록 조회)") + @Nested + class GetProducts { + + @DisplayName("상품이 존재하면, 목록을 반환한다.") + @Test + void returnsProducts_whenProductsExist() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + registerProduct(brandId, "에어포스 1", 119000L, 50, 3); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_PUBLIC, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().products()).hasSize(2) + ); + } + + @DisplayName("키워드로 상품명을 검색하면, 일치하는 상품만 반환한다.") + @Test + void returnsFilteredProducts_whenKeywordMatchesProductName() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + registerProduct(brandId, "에어포스 1", 119000L, 50, 3); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_PUBLIC + "?keyword=에어맥스", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().products()).hasSize(1), + () -> assertThat(response.getBody().data().products().get(0).name()).isEqualTo("에어맥스 90") + ); + } + + @DisplayName("키워드로 브랜드명을 검색하면, 해당 브랜드의 상품을 반환한다.") + @Test + void returnsFilteredProducts_whenKeywordMatchesBrandName() { + // Arrange + Long nikeId = registerBrand("Nike", "Just Do It"); + Long adidasId = registerBrand("Adidas", "Impossible Is Nothing"); + registerProduct(nikeId, "에어맥스 90", 139000L, 100, 5); + registerProduct(adidasId, "울트라부스트", 189000L, 30, 2); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_PUBLIC + "?keyword=Nike", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().products()).hasSize(1), + () -> assertThat(response.getBody().data().products().get(0).name()).isEqualTo("에어맥스 90") + ); + } + + @DisplayName("브랜드 ID로 필터하면, 해당 브랜드의 상품만 반환한다.") + @Test + void returnsFilteredProducts_whenBrandIdProvided() { + // Arrange + Long nikeId = registerBrand("Nike", "Just Do It"); + Long adidasId = registerBrand("Adidas", "Impossible Is Nothing"); + registerProduct(nikeId, "에어맥스 90", 139000L, 100, 5); + registerProduct(adidasId, "울트라부스트", 189000L, 30, 2); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_PUBLIC + "?brandId=" + nikeId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().products()).hasSize(1), + () -> assertThat(response.getBody().data().products().get(0).name()).isEqualTo("에어맥스 90") + ); + } + + @DisplayName("가격 오름차순으로 정렬하면, 가격 순서대로 반환한다.") + @Test + void returnsSortedProducts_whenSortByPriceAsc() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + registerProduct(brandId, "에어포스 1", 119000L, 50, 3); + registerProduct(brandId, "덩크 로우", 159000L, 80, 4); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_PUBLIC + "?sort=PRICE_ASC", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().products()).hasSize(3), + () -> assertThat(response.getBody().data().products().get(0).price()).isEqualTo(119000L), + () -> assertThat(response.getBody().data().products().get(1).price()).isEqualTo(139000L), + () -> assertThat(response.getBody().data().products().get(2).price()).isEqualTo(159000L) + ); + } + } + + @DisplayName("GET /api/v1/products/{productId} (상품 상세 조회)") + @Nested + class GetProduct { + + @DisplayName("존재하는 상품을 조회하면, 브랜드 정보가 포함된 200 OK 응답을 받는다.") + @Test + void returnsOkWithBrandInfo_whenProductExists() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_PUBLIC + "/" + productId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().product().name()).isEqualTo("에어맥스 90"), + () -> assertThat(response.getBody().data().product().price()).isEqualTo(139000L), + () -> assertThat(response.getBody().data().product().brand().name()).isEqualTo("Nike") + ); + } + + @DisplayName("존재하지 않는 상품을 조회하면, 404 Not Found 응답을 받는다.") + @Test + void returnsNotFound_whenProductNotExists() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_PUBLIC + "/999", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PUT /api-admin/v1/products/{productId} (상품 수정)") + @Nested + class Update { + + @DisplayName("유효한 정보로 수정하면, 200 OK 응답을 받는다.") + @Test + void returnsOk_whenValidRequest() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + var request = new ProductV1Dto.UpdateRequest("에어맥스 95", "업데이트된 설명", 149000L, 3); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN + "/" + productId, + HttpMethod.PUT, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 수정 확인 + ResponseEntity> getResponse = testRestTemplate.exchange( + PRODUCT_PUBLIC + "/" + productId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + assertAll( + () -> assertThat(getResponse.getBody().data().product().name()).isEqualTo("에어맥스 95"), + () -> assertThat(getResponse.getBody().data().product().price()).isEqualTo(149000L), + () -> assertThat(getResponse.getBody().data().product().maxOrderQuantity()).isEqualTo(3) + ); + } + + @DisplayName("존재하지 않는 상품을 수정하면, 404 Not Found 응답을 받는다.") + @Test + void returnsNotFound_whenProductNotExists() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN + "/999", + HttpMethod.PUT, + adminEntity(new ProductV1Dto.UpdateRequest("이름", "설명", 10000L, 5)), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{productId} (상품 삭제)") + @Nested + class Delete { + + @DisplayName("존재하는 상품을 삭제하면, 200 OK 응답을 받고 조회 시 404가 반환된다.") + @Test + void returnsOk_whenProductExists() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN + "/" + productId, + HttpMethod.DELETE, + adminEntity(null), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 삭제 후 조회 시 NOT_FOUND + ResponseEntity> getResponse = testRestTemplate.exchange( + PRODUCT_PUBLIC + "/" + productId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("존재하지 않는 상품을 삭제하면, 404 Not Found 응답을 받는다.") + @Test + void returnsNotFound_whenProductNotExists() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN + "/999", + HttpMethod.DELETE, + adminEntity(null), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PATCH /api-admin/v1/products/{productId}/stock (재고 수정)") + @Nested + class UpdateStock { + + @DisplayName("유효한 수량으로 수정하면, 200 OK 응답을 받는다.") + @Test + void returnsOk_whenValidRequest() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN + "/" + productId + "/stock", + HttpMethod.PATCH, + adminEntity(new ProductV1Dto.UpdateStockRequest(200)), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // 재고 수정 확인 + ResponseEntity> getResponse = testRestTemplate.exchange( + PRODUCT_PUBLIC + "/" + productId, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + assertThat(getResponse.getBody().data().product().stockQuantity()).isEqualTo(200); + } + + @DisplayName("음수 수량으로 수정하면, 400 Bad Request 응답을 받는다.") + @Test + void returnsBadRequest_whenQuantityIsNegative() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN + "/" + productId + "/stock", + HttpMethod.PATCH, + adminEntity(new ProductV1Dto.UpdateStockRequest(-1)), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 상품의 재고를 수정하면, 404 Not Found 응답을 받는다.") + @Test + void returnsNotFound_whenProductNotExists() { + // Act + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN + "/999/stock", + HttpMethod.PATCH, + adminEntity(new ProductV1Dto.UpdateStockRequest(200)), + new ParameterizedTypeReference<>() {} + ); + + // Assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("브랜드 삭제 시 상품 cascade 삭제") + @Nested + class BrandCascadeDelete { + + @DisplayName("브랜드를 삭제하면, 해당 브랜드의 모든 상품도 삭제된다.") + @Test + void deletesProducts_whenBrandDeleted() { + // Arrange + Long brandId = registerBrand("Nike", "Just Do It"); + Long productId1 = registerProduct(brandId, "에어맥스 90", 139000L, 100, 5); + Long productId2 = registerProduct(brandId, "에어포스 1", 119000L, 50, 3); + + // Act + testRestTemplate.exchange( + "/api-admin/v1/brands/" + brandId, + HttpMethod.DELETE, + adminEntity(null), + new ParameterizedTypeReference>() {} + ); + + // Assert - 상품 조회 시 NOT_FOUND + ResponseEntity> response1 = testRestTemplate.exchange( + PRODUCT_PUBLIC + "/" + productId1, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + ResponseEntity> response2 = testRestTemplate.exchange( + PRODUCT_PUBLIC + "/" + productId2, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + assertAll( + () -> assertThat(response1.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(response2.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } + + // Helper methods + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpEntity adminEntity(T body) { + return new HttpEntity<>(body, adminHeaders()); + } + + private Long registerBrand(String name, String description) { + var request = new BrandV1Dto.RegisterRequest(name, description); + ResponseEntity> response = testRestTemplate.exchange( + BRAND_ADMIN, + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().brand().id(); + } + + private Long registerProduct(Long brandId, String name, Long price, int stock, int maxOrder) { + var request = new ProductV1Dto.RegisterRequest(brandId, name, "설명", price, stock, maxOrder); + ResponseEntity> response = testRestTemplate.exchange( + PRODUCT_ADMIN, + HttpMethod.POST, + adminEntity(request), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().product().id(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/support/auth/AdminAuthFilterE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/support/auth/AdminAuthFilterE2ETest.java new file mode 100644 index 000000000..142ec968c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/support/auth/AdminAuthFilterE2ETest.java @@ -0,0 +1,88 @@ +package com.loopers.support.auth; + +import com.loopers.interfaces.api.ApiResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminAuthFilterE2ETest { + + private static final String ADMIN_ENDPOINT = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + + @Autowired + public AdminAuthFilterE2ETest(TestRestTemplate testRestTemplate) { + this.testRestTemplate = testRestTemplate; + } + + @DisplayName("X-Loopers-Ldap 헤더 없이 Admin API에 접근하면, 401 응답을 받는다.") + @Test + void returnsUnauthorized_whenNoLdapHeader() { + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("잘못된 X-Loopers-Ldap 헤더로 Admin API에 접근하면, 401 응답을 받는다.") + @Test + void returnsUnauthorized_whenInvalidLdapHeader() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong.value"); + + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("올바른 X-Loopers-Ldap 헤더로 Admin API에 접근하면, 인증을 통과한다.") + @Test + void passesAuthentication_whenValidLdapHeader() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(headers), + new ParameterizedTypeReference<>() {} + ); + + // 인증 통과 후 실제 API가 동작하므로 401이 아닌 다른 응답 + assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.UNAUTHORIZED); + } + + @DisplayName("Public API는 X-Loopers-Ldap 헤더 없이도 접근 가능하다.") + @Test + void allowsPublicApi_withoutLdapHeader() { + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/brands", + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isNotEqualTo(HttpStatus.UNAUTHORIZED); + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..dc167f2e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 8787efdc4..1c635bcd3 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -29,11 +29,11 @@ | ID | 기능 | 액터 | URI | 설명 | 구현 상태 | |----|------|------|-----|------|-----------| -| U-1 | 회원가입 | Guest | `POST /api/v1/members` | loginId, password, name, birthDate, gender, email, phone | 기존 구현 (gender, phone 추가 필요) | -| U-2 | 내 정보 조회 | Member | `GET /api/v1/members/me` | 이름 마지막 글자 마스킹 | 기존 구현 | -| U-3 | 비밀번호 변경 | Member | `PATCH /api/v1/members/me/password` | 현재 비밀번호 검증 + 새 비밀번호 규칙 | 기존 구현 | -| U-4 | 내 정보 수정 | Member | `PATCH /api/v1/members/me` | 전화번호, 프로필 사진 수정 | 신규 | -| U-5 | 회원 탈퇴 | Member | `DELETE /api/v1/members/me` | 비밀번호 확인 후 soft delete | 신규 | +| U-1 | 회원가입 | Guest | `POST /api/v1/members` | loginId, password, name, birthDate, gender, email, phone | 구현 완료 | +| U-2 | 내 정보 조회 | Member | `GET /api/v1/members/me` | 이름 마지막 글자 마스킹 | 구현 완료 | +| U-3 | 비밀번호 변경 | Member | `PATCH /api/v1/members/me/password` | 현재 비밀번호 검증 + 새 비밀번호 규칙 | 구현 완료 | +| U-4 | 내 정보 수정 (전화번호) | Member | `PATCH /api/v1/members/me` | 전화번호 수정 | 구현 완료 | +| U-5 | 회원 탈퇴 | Member | `DELETE /api/v1/members/me` | 비밀번호 확인 후 soft delete | 구현 완료 | **검증 규칙:** - loginId: 영문+숫자만, unique @@ -42,8 +42,7 @@ - gender: MALE / FEMALE - email: 이메일 형식 검증 - phone: 전화번호 형식 검증 -- profileImageUrl: URL 형식, nullable (파일 업로드 후 설정) -- 인증: `MemberAuthFilter`에서 헤더 추출 → `request.setAttribute("authenticatedMember", member)` +- 인증: `MemberAuthFilter`에서 헤더 추출 → `request.setAttribute("authenticatedLoginId", loginId)` → Controller에서 loginId로 수신, Facade에서 Member 조회 - 탈퇴: 비밀번호 확인 필수, soft delete 처리, 탈퇴 후 동일 loginId 재가입 불가 **경계 조건:** @@ -58,14 +57,14 @@ | ID | 기능 | 액터 | URI | 설명 | |----|------|------|-----|------| -| A-1 | 배송지 목록 조회 | Member | `GET /api/v1/members/me/addresses?page=&size=` | 내 배송지 전체 | +| A-1 | 배송지 목록 조회 | Member | `GET /api/v1/members/me/addresses` | 내 배송지 전체 (페이징 없음) | | A-2 | 배송지 등록 | Member | `POST /api/v1/members/me/addresses` | 첫 등록 시 자동 기본 배송지 | | A-3 | 배송지 수정 | Member | `PUT /api/v1/members/me/addresses/{addressId}` | 수령인, 주소 등 | | A-4 | 배송지 삭제 | Member | `DELETE /api/v1/members/me/addresses/{addressId}` | 삭제 | | A-5 | 기본 배송지 설정 | Member | `PATCH /api/v1/members/me/addresses/{addressId}/default` | 기본 배송지 변경 | **비즈니스 규칙:** -- 회원당 배송지 여러 개 등록 가능 +- 회원당 배송지 **최대 10개** 등록 가능 - 하나의 배송지를 기본 배송지로 지정 (`isDefault`) - 첫 번째 배송지 등록 시 자동으로 기본 배송지 설정 - **기본 배송지는 삭제 불가** — 다른 배송지를 기본으로 변경 후 삭제 가능 @@ -114,9 +113,9 @@ | ID | 기능 | 액터 | URI | 설명 | |----|------|------|-----|------| | P-1 | 상품 목록 조회 | Guest/Member | `GET /api/v1/products?keyword=&brandId=&sort=latest&page=0&size=20` | 키워드 검색+페이징+정렬+브랜드필터 | -| P-2 | 상품 상세 조회 | Guest/Member | `GET /api/v1/products/{productId}` | 이름, 가격, 설명, 재고, 브랜드, 좋아요 수, 이미지 | -| P-3 | 상품 등록 | Admin | `POST /api-admin/v1/products` | 브랜드 존재 필수, 이미지 URL, 최대 주문 수량 | -| P-4 | 상품 수정 | Admin | `PUT /api-admin/v1/products/{productId}` | **브랜드 변경 불가**, 이미지/최대 주문 수량 변경 가능 | +| P-2 | 상품 상세 조회 | Guest/Member | `GET /api/v1/products/{productId}` | 이름, 가격, 설명, 재고, 브랜드, 좋아요 수 | +| P-3 | 상품 등록 | Admin | `POST /api-admin/v1/products` | 브랜드 존재 필수, 최대 주문 수량 | +| P-4 | 상품 수정 | Admin | `PUT /api-admin/v1/products/{productId}` | **브랜드 변경 불가**, 최대 주문 수량 변경 가능 | | P-5 | 상품 삭제 | Admin | `DELETE /api-admin/v1/products/{productId}` | soft delete | **비즈니스 규칙:** @@ -125,13 +124,12 @@ - 가격은 양수, 재고는 0 이상, 최대 주문 수량은 양수 - 최대 주문 수량(`maxOrderQuantity`): 1회 주문 시 해당 상품을 주문할 수 있는 최대 수량 (Admin이 등록/수정 시 설정) - 삭제된 상품은 조회 불가하지만 기존 주문의 스냅샷에는 영향 없음 -- 정렬: `latest`(필수), `price_asc`, `likes_desc`(선택) -- imageUrl: nullable (이미지 없는 상품 허용) +- 정렬: `latest`(필수), `price_asc`, `price_desc`, `likes_desc`(선택) - 상품 목록: `keyword` 파라미터로 상품명 또는 브랜드명 검색 (LIKE), 미입력 시 전체 목록 - Admin은 조회 시 Public API 사용 (별도 Admin 조회 API 없음) **경계 조건:** -- 존재하지 않는 브랜드로 상품 등록 → BAD_REQUEST +- 존재하지 않는 브랜드로 상품 등록 → NOT_FOUND - 삭제된 상품 조회 → NOT_FOUND - 존재하지 않는 상품 수정/삭제 → NOT_FOUND - 재고 0인 상품: 조회 가능, 주문 불가 @@ -144,14 +142,16 @@ **사용자 관점:** 마음에 드는 상품이나 브랜드에 좋아요를 표시하고, 내가 좋아요한 목록을 관리한다. -#### 5-1. 상품 좋아요 (ProductLike) +**구현 구조:** 단일 `Like` 엔티티 + `LikeTargetType` enum (PRODUCT, BRAND)으로 통합 구현. 별도의 ProductLike/BrandLike 엔티티 없이 `target_type`으로 구분한다. + +#### 5-1. 상품 좋아요 | ID | 기능 | 액터 | URI | 설명 | |----|------|------|-----|------| | PL-1 | 상품 좋아요 토글 | Member | `POST /api/v1/products/{productId}/likes` | 좋아요 ↔ 취소 토글 | | PL-2 | 내 상품 좋아요 목록 | Member | `GET /api/v1/members/me/likes/products?page=&size=` | 본인만 조회 가능 | -#### 5-2. 브랜드 좋아요 (BrandLike) +#### 5-2. 브랜드 좋아요 | ID | 기능 | 액터 | URI | 설명 | |----|------|------|-----|------| @@ -164,6 +164,8 @@ - 토글 응답에 현재 상태 포함 (`liked: true/false`, `likeCount`) - 좋아요 목록은 **본인만 조회 가능** (타인 접근 불가) - 상품 좋아요 수는 상품 목록의 `likes_desc` 정렬에 활용 +- 단일 `likes` 테이블에서 `target_type`으로 상품/브랜드 좋아요를 구분 +- Like는 BaseEntity를 상속하지 않으며, 자체 id/createdAt/updatedAt 관리 (deleted_at 없음, hard delete) **경계 조건:** - 존재하지 않는 대상에 좋아요 → NOT_FOUND @@ -212,8 +214,9 @@ **경계 조건:** - 빈 items 배열 → BAD_REQUEST - quantity <= 0 → BAD_REQUEST -- addressId 누락 또는 존재하지 않는 배송지 → BAD_REQUEST -- 타인의 배송지로 주문 시도 → BAD_REQUEST +- addressId 누락 → BAD_REQUEST +- 존재하지 않는 배송지 → NOT_FOUND +- 타인의 배송지로 주문 시도 → NOT_FOUND - 삭제된 상품으로 주문 → NOT_FOUND - 재고 부족 → BAD_REQUEST (어떤 상품이 부족한지 메시지 포함) - 주문 수량이 상품의 maxOrderQuantity 초과 → BAD_REQUEST @@ -267,23 +270,9 @@ --- -### 8. 파일 업로드 (File) - -**사용자 관점:** 프로필 사진이나 상품 이미지를 업로드한다. - -| ID | 기능 | 액터 | URI | 설명 | -|----|------|------|-----|------| -| F-1 | 파일 업로드 | Member/Admin | `POST /api/v1/files` | 이미지 업로드 → URL 반환 | - -**비즈니스 규칙:** -- 업로드된 파일은 스토리지에 저장 후 접근 가능한 URL을 반환 -- 반환된 URL을 Member.profileImageUrl 또는 Product.imageUrl에 설정 -- 허용 파일 형식: 이미지 (JPEG, PNG, WEBP) -- 파일 크기 상한 설정 필요 +### 8. 파일 업로드 (File) — Out of Scope -**경계 조건:** -- 허용되지 않은 파일 형식 → BAD_REQUEST -- 파일 크기 초과 → BAD_REQUEST +> **미구현.** profileImageUrl, imageUrl 필드와 함께 후속 단계에서 구현 예정. --- @@ -297,30 +286,31 @@ | 주문 취소 | 재고 전체 복원 | | 동시성 제어 | 기본 기능 구현 후 별도 단계 | | 좋아요 방식 | 토글 (POST 1개 엔드포인트, 있으면 취소 / 없으면 등록) | +| 좋아요 구조 | 단일 Like 엔티티 + LikeTargetType enum (PRODUCT, BRAND) | | 좋아요 수 전략 | Product/Brand에 like_count 비정규화 컬럼 | | 주문 동일상품 중복 | 수량 합산 처리 | | 키워드 검색 | 상품 목록은 상품명+브랜드명 LIKE 검색, 브랜드 목록은 브랜드명 LIKE 검색 | | Admin 조회 | Brand/Product는 Public API 공유, Order만 Admin 전용 조회 | | 좋아요 목록 | 본인만 조회 가능 | -| 배송지 관리 | Address 별도 엔티티 (Member:Address = 1:N), 주문에 스냅샷 | +| 배송지 관리 | Address 별도 엔티티 (Member:Address = 1:N, 최대 10개), 주문에 스냅샷 | | 주문 배송지 | 원본 Address와 독립적으로 수정 가능 | -| 파일 업로드 | URL 반환 방식, 프로필 사진/상품 이미지에 사용 | +| 인증 전달 | MemberAuthFilter → authenticatedLoginId (String) → Facade에서 Member 조회 | --- ## 구현 범위 -### In Scope -- Member (기존 유지 + gender, phone, profileImageUrl 추가) -- Address (배송지 관리) -- Brand, Product (imageUrl 포함) -- ProductLike, BrandLike +### In Scope (구현 완료) +- Member (loginId, password, name, birthDate, gender, email, phone) +- Address (배송지 관리, 최대 10개) +- Brand, Product +- Like (단일 엔티티, LikeTargetType으로 상품/브랜드 구분) - Order, OrderItem (배송지 스냅샷 포함) - Admin (LDAP 인증, 브랜드/상품 CUD, 재고 수정, 회원 조회, 주문 조회) - 주문 취소, 주문 배송지 수정 -- 파일 업로드 (이미지) -### Out Scope +### Out of Scope +- 파일 업로드 (이미지) — profileImageUrl, imageUrl 포함 - 결제 (PG 연동) - 배송 추적 / 배송 상태 관리 - 환불 diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index b3701f683..d93cf31eb 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -27,7 +27,7 @@ ## 1. 인증 흐름 -모든 인증 필요 API의 전제조건. Filter가 요청을 가로채 인증을 수행하며, 이후 흐름의 `authenticatedMember` 출처를 명확히 한다. +모든 인증 필요 API의 전제조건. Filter가 요청을 가로채 인증을 수행하며, 이후 흐름의 `authenticatedLoginId` 출처를 명확히 한다. ### 1-1. 회원 인증 (MemberAuthFilter) @@ -48,8 +48,8 @@ sequenceDiagram alt 인증 실패 (미존재/비밀번호 불일치/탈퇴 회원) Filter-->>Client: 401 Unauthorized else 인증 성공 - Filter->>Filter: setAttribute("authenticatedMember") - Note right of Filter: Controller가 @RequestAttribute로 수신 + Filter->>Filter: setAttribute("authenticatedLoginId") + Note right of Filter: Controller가 @RequestAttribute로 loginId 수신 end end ``` @@ -73,6 +73,7 @@ sequenceDiagram **핵심 포인트:** - Member 인증: 매 요청마다 loginId + password 검증 (세션/토큰 없음) +- 인증 성공 시 `authenticatedLoginId` (String)을 request attribute로 전달 → Controller에서 loginId로 수신 → Facade에서 Member 조회 - Admin 인증: LDAP 헤더 값만 확인, DB 조회 없음 - 인증 불필요: 상품/브랜드 조회 (URL 패턴 제외) - 탈퇴(soft delete) 회원은 조회 시 제외 → 인증 실패 @@ -82,39 +83,41 @@ sequenceDiagram ## 2. 좋아요 토글 Like 생성/삭제와 like_count 동기화가 하나의 트랜잭션에서 이루어져야 한다. +단일 `Like` 엔티티 + `LikeTargetType` enum으로 상품/브랜드 좋아요를 통합 처리한다. ```mermaid sequenceDiagram actor User - participant Facade as ProductLikeFacade + participant Facade as LikeFacade participant ProductService - participant LikeService as ProductLikeService + participant LikeService Note over Facade, LikeService: @Transactional - User->>Facade: toggleLike(member, productId) + User->>Facade: toggleProductLike(loginId, productId) Facade->>ProductService: getProduct(productId) alt 상품 미존재 ProductService-->>Facade: NOT_FOUND end - Facade->>LikeService: toggleLike(memberId, productId) + Facade->>LikeService: toggleLike(memberId, PRODUCT, productId) alt 좋아요 없음 → 등록 LikeService-->>Facade: liked = true Facade->>ProductService: increaseLikeCount(productId) - else 좋아요 있음 → 취소 + else 좋아요 있음 → 취소 (hard delete) LikeService-->>Facade: liked = false Facade->>ProductService: decreaseLikeCount(productId) end - Facade-->>User: LikeToggleResult (liked, likeCount) + Facade-->>User: LikeToggleInfo (liked, likeCount) ``` **핵심 포인트:** - 단일 POST 토글 (있으면 취소, 없으면 등록) - like_count 증감은 동일 트랜잭션 내 처리 -- **브랜드 좋아요도 동일 패턴** (BrandLikeFacade → BrandService + BrandLikeService) +- 단일 `LikeFacade`/`LikeService`로 상품·브랜드 좋아요 모두 처리 +- **브랜드 좋아요도 동일 패턴** (LikeFacade.toggleBrandLike → BrandService + LikeService) --- @@ -131,14 +134,10 @@ sequenceDiagram Note over Facade, ProductService: @Transactional - Admin->>Facade: deleteBrand(brandId) + Admin->>Facade: delete(brandId) - Facade->>BrandService: getBrand(brandId) - alt 브랜드 미존재 - BrandService-->>Facade: NOT_FOUND - end - - Facade->>BrandService: delete(brand) + Facade->>BrandService: delete(brandId) + Note right of BrandService: 브랜드 존재 검증 (미존재 시 NOT_FOUND) Note right of BrandService: brand.deleted_at = now() Facade->>ProductService: deleteAllByBrandId(brandId) @@ -165,33 +164,37 @@ sequenceDiagram Note over Facade, OrderService: @Transactional - User->>Facade: createOrder(member, items, addressId) + User->>Facade: createOrder(loginId, addressId, items) Facade->>AddressService: getAddress(addressId, memberId) alt 배송지 미존재 or 소유권 검증 실패 - AddressService-->>Facade: BAD_REQUEST + AddressService-->>Facade: NOT_FOUND end - Note right of Facade: 동일 상품 합산 처리 + Facade->>OrderService: mergeOrderItems(items) + Note right of OrderService: 동일 상품 수량 합산 + 빈 목록 검증 - loop 상품별 처리 + loop 합산된 상품별 처리 Facade->>ProductService: getProduct(productId) alt 상품 미존재/삭제 ProductService-->>Facade: NOT_FOUND end + Note right of Facade: product.validateOrderQuantity(qty) alt 주문 수량 > maxOrderQuantity Facade-->>User: BAD_REQUEST end - Facade->>ProductService: decreaseStock(product, quantity) + Note right of Facade: product.decreaseStock(qty) alt 재고 부족 - ProductService-->>Facade: BAD_REQUEST (ROLLBACK) + Facade-->>User: BAD_REQUEST (ROLLBACK) end end Note right of Facade: totalAmount 계산 - Facade->>OrderService: createOrder(memberId, address, items, totalAmount) - Note right of OrderService: Order(COMPLETED) + OrderItem(스냅샷) 생성 + Facade->>OrderService: createOrder(memberId, 배송지 스냅샷, totalAmount) + Note right of OrderService: Order(COMPLETED) 생성 + Facade->>OrderService: createOrderItems(orderId, 상품 스냅샷 목록) + Note right of OrderService: OrderItem(스냅샷) 생성 Facade-->>User: OrderInfo ``` @@ -216,23 +219,20 @@ sequenceDiagram Note over Facade, ProductService: @Transactional - User->>Facade: cancelOrder(member, orderId) + User->>Facade: cancelOrder(loginId, orderId) - Facade->>OrderService: getOrder(orderId, memberId) - alt 주문 미존재 or 소유권 검증 실패 - OrderService-->>Facade: NOT_FOUND - end - alt 이미 CANCELLED + Facade->>OrderService: cancelOrder(orderId, memberId) + Note right of OrderService: 소유권 검증 + 취소 상태 체크 + alt 주문 미존재 or 소유권 검증 실패 or 이미 CANCELLED OrderService-->>Facade: NOT_FOUND end + Note right of OrderService: order.cancel() → status = CANCELLED + Note right of OrderService: OrderItem 목록 조회 + OrderService-->>Facade: List - Facade->>OrderService: cancel(order) - Note right of OrderService: status → CANCELLED - - Facade->>OrderService: getOrderItems(orderId) - - loop OrderItem별 처리 - Facade->>ProductService: increaseStock(productId, quantity) + loop OrderItem별 재고 복원 (cross-domain orchestration) + Facade->>ProductService: getProduct(productId) + Note right of Facade: product.increaseStock(qty) end ``` @@ -247,7 +247,7 @@ sequenceDiagram | 기능 | 트랜잭션 소유자 | 참여 도메인 | 비고 | |------|----------------|-------------|------| | 인증 | MemberAuthFilter | Member | 트랜잭션 없음 | -| 좋아요 토글 | ProductLikeFacade / BrandLikeFacade | Like, Product/Brand | like_count 동기화 | +| 좋아요 토글 | LikeFacade | Like, Product/Brand | like_count 동기화, 단일 Facade로 상품·브랜드 모두 처리 | | 브랜드 삭제 | BrandFacade | Brand, Product | cascade soft delete | | 상품 등록 | ProductFacade | Brand(검증), Product | 브랜드 존재 확인 | | 주문 생성 | OrderFacade | Address, Product, Order | 재고 선차감, 실패 시 전체 롤백 | diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index 5b641b7a6..393c425d7 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -21,6 +21,8 @@ classDiagram -Gender gender +create(...)$ Member +changePassword(...) + +updatePhone(phone) + +withdraw(rawPassword, encoder) } class Address { @@ -28,7 +30,9 @@ classDiagram -Long memberId -Boolean isDefault +create(...)$ Address - +setDefault(boolean) + +update(...) + +changeDefault(boolean) + +delete() } class Gender { @@ -47,13 +51,6 @@ classDiagram +increaseLikeCount() +decreaseLikeCount() } - - class BrandLike { - -Long id - -Long memberId - -Long brandId - +create(...)$ BrandLike - } } namespace 상품 { @@ -71,23 +68,36 @@ classDiagram +increaseStock(quantity) +increaseLikeCount() +decreaseLikeCount() - } - - class ProductLike { - -Long id - -Long memberId - -Long productId - +create(...)$ ProductLike + +validateOrderQuantity(quantity) } class ProductSortType { <> LATEST PRICE_ASC + PRICE_DESC LIKES_DESC } } + namespace 좋아요 { + class Like { + -Long id + -Long memberId + -LikeTargetType targetType + -Long targetId + -ZonedDateTime createdAt + -ZonedDateTime updatedAt + +create(...)$ Like + } + + class LikeTargetType { + <> + PRODUCT + BRAND + } + } + namespace 주문 { class Order { -Long id @@ -118,14 +128,15 @@ classDiagram } note for Order "배송지 스냅샷 포함 (5개 필드)" + note for Like "BaseEntity 미상속, hard delete, 자체 생명주기" Member --> Gender Address "*" --> "1" Member : memberId Product "*" --> "1" Brand : brandId - ProductLike "*" --> "1" Member : memberId - ProductLike "*" --> "1" Product : productId - BrandLike "*" --> "1" Member : memberId - BrandLike "*" --> "1" Brand : brandId + Like "*" --> "1" Member : memberId + Like --> LikeTargetType + Like "*" --> "0..1" Product : targetId (PRODUCT) + Like "*" --> "0..1" Brand : targetId (BRAND) Order "*" --> "1" Member : memberId Order "1" *-- "1..*" OrderItem OrderItem "*" --> "1" Product : productId (snapshot) @@ -140,14 +151,19 @@ classDiagram | 위치 | 책임 | |------|------| | **Entity** | 생성 검증 (factory method), 상태 변경, 비즈니스 규칙 캡슐화 | -| **Service** | Entity 조회/저장 조율, Reader/Repository 사용 | -| **Facade** | 다중 도메인 협력 조율, 트랜잭션 경계 | +| **Service** | 단일 도메인 조율 (Reader/Repository 사용) | +| **Facade** | 다중 도메인 협력 조율, 트랜잭션 경계. Facade는 Reader에 직접 의존하지 않고 Service를 통해 접근 | ### 2. Entity에 위치하는 핵심 로직 +**Member** +- `withdraw(rawPassword, encoder)`: 비밀번호 검증 후 soft delete. +- `updatePhone(phone)`: 전화번호 형식 검증 후 변경. + **Address** - `create()`: 필수 정보 검증 (수령인, 전화번호, 주소). isDefault는 파라미터로 수신 (첫 등록 여부는 Service에서 판단). -- `setDefault()`: 기본 배송지 지정/해제. +- `changeDefault()`: 기본 배송지 지정/해제. +- `delete()`: **기본 배송지는 삭제 불가** 규칙 캡슐화. 기본 배송지면 예외 발생. **Brand** - `increaseLikeCount()`: 좋아요 수 1 증가. @@ -158,6 +174,7 @@ classDiagram - `decreaseStock(qty)`: 재고 >= qty 검증 후 차감. 도메인 불변식 보호. - `increaseStock(qty)`: qty > 0 검증 후 증가. - `update()`: 브랜드 변경 불가 — update에 brandId 파라미터 없음. +- `validateOrderQuantity(qty)`: qty가 maxOrderQuantity 초과 시 예외. 주문 시 검증용. - `increaseLikeCount()`: 좋아요 수 1 증가. - `decreaseLikeCount()`: 좋아요 수 1 감소. 0 미만 방지. @@ -174,11 +191,11 @@ classDiagram | Address | Entity | 고유 ID, 독립 생명주기 (Member 소유) | | Brand | Entity | 고유 ID, 독립 생명주기 | | Product | Entity | 고유 ID, 독립 생명주기 | -| ProductLike | Entity | 고유 ID, DB에 독립 저장 | -| BrandLike | Entity | 고유 ID, DB에 독립 저장 | +| Like | Entity | 고유 ID, DB에 독립 저장. BaseEntity 미상속 (자체 생명주기, hard delete) | | Order | Entity | 고유 ID, 상태 전이 존재 | | OrderItem | Entity (DB) / VO (개념) | DB 저장 필요하나 Order 없이 독립 존재 불가 | | Gender | Enum | 성별 값 | +| LikeTargetType | Enum | 좋아요 대상 구분 (PRODUCT, BRAND) | | ProductSortType | Enum | 정렬 기준 값 | | OrderStatus | Enum | 주문 상태 값 | @@ -190,15 +207,14 @@ classDiagram |------|--------|-----------| | Address → Member | * : 1 | memberId | | Product → Brand | * : 1 | brandId | -| ProductLike → Member | * : 1 | memberId | -| ProductLike → Product | * : 1 | productId | -| BrandLike → Member | * : 1 | memberId | -| BrandLike → Brand | * : 1 | brandId | +| Like → Member | * : 1 | memberId | +| Like → Product/Brand | * : 0..1 | targetId + targetType (다형성 참조) | | Order → Member | * : 1 | memberId | | Order ◆ OrderItem | 1 : 1..* | composition | | OrderItem → Product | * : 1 | productId (snapshot) | - JPA의 `@ManyToOne` 양방향 매핑은 사용하지 않음. ID 참조로 도메인 간 결합도를 낮춤. +- Like는 `targetType` + `targetId`로 상품/브랜드를 다형성 참조 (단일 테이블 전략). - Order의 배송지: ID 참조 아닌 **값 스냅샷** (recipientName, recipientPhone, zipCode, address1, address2) --- @@ -222,6 +238,13 @@ application/{domain}/ └── {Domain}Info.java # 전달 객체 (record) interfaces/api/{domain}/ -├── {Domain}V1Controller.java # REST Controller +├── {Domain}V1Controller.java # REST Controller (Public API) +├── {Domain}AdminV1Controller.java # REST Controller (Admin API, 해당 시) └── {Domain}V1Dto.java # Request/Response DTO ``` + +**좋아요(Like) 도메인 특이사항:** +- 단일 `Like` 엔티티 + `LikeTargetType` enum으로 상품/브랜드 좋아요 통합 +- `LikeFacade` 하나에서 `toggleProductLike()`, `toggleBrandLike()` 모두 처리 +- `LikeService`가 targetType을 파라미터로 받아 범용 토글/조회 수행 +- Controller는 `LikeV1Controller` 하나에 상품/브랜드 좋아요 엔드포인트 모두 포함 diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 9e209b3c8..f64bf4f19 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -32,7 +32,6 @@ erDiagram varchar gender "not null, MALE or FEMALE" varchar email "not null" varchar phone "not null" - varchar profile_image_url "nullable" datetime created_at "not null" datetime updated_at "not null" datetime deleted_at "nullable" @@ -72,24 +71,16 @@ erDiagram int stock_quantity "not null, >= 0" int max_order_quantity "not null, > 0" int like_count "not null, default 0" - varchar image_url "nullable" datetime created_at "not null" datetime updated_at "not null" datetime deleted_at "nullable" } - product_like { + likes { bigint id PK bigint member_id FK "not null" - bigint product_id FK "not null" - datetime created_at "not null" - datetime updated_at "not null" - } - - brand_like { - bigint id PK - bigint member_id FK "not null" - bigint brand_id FK "not null" + varchar target_type "not null, PRODUCT or BRAND" + bigint target_id FK "not null" datetime created_at "not null" datetime updated_at "not null" } @@ -123,10 +114,7 @@ erDiagram member ||--o{ address : "has" brand ||--o{ product : "has" - member ||--o{ product_like : "likes product" - product ||--o{ product_like : "liked by" - member ||--o{ brand_like : "likes brand" - brand ||--o{ brand_like : "liked by" + member ||--o{ likes : "likes" member ||--o{ orders : "places" orders ||--|{ order_item : "contains" product ||--o{ order_item : "ordered as" @@ -153,11 +141,8 @@ erDiagram - `INDEX: idx_product_price (price)` — price_asc 정렬 - `INDEX: idx_product_name (name)` — 상품명 키워드 검색 -### product_like -- `UK: uk_product_like_member_product (member_id, product_id)` — 중복 좋아요 방지 (leftmost prefix로 member_id 조회 겸용) - -### brand_like -- `UK: uk_brand_like_member_brand (member_id, brand_id)` — 중복 좋아요 방지 (leftmost prefix로 member_id 조회 겸용) +### likes +- `UK: uk_like_member_target (member_id, target_type, target_id)` — 동일 대상 중복 좋아요 방지 (leftmost prefix로 member_id 조회 겸용) ### orders - `INDEX: idx_orders_member_id_created_at (member_id, created_at)` — 내 주문 목록 + 날짜 범위 필터 @@ -176,7 +161,9 @@ erDiagram | 테이블명 | Order | `orders` | `order`는 SQL 예약어 | | like_count | Product/Brand 필드 | product.like_count, brand.like_count 컬럼 | 비정규화 (성능 최적화) | | Order 배송지 | Address에서 가져옴 | orders에 snapshot 컬럼으로 저장 | 주문 시점 배송지 보존, 원본과 독립 | +| Like 구조 | 단일 Like 엔티티 (targetType + targetId) | `likes` 단일 테이블 | 다형성 참조로 상품/브랜드 좋아요 통합 | | Like 삭제 | 토글 취소 시 삭제 | `deleted_at` 없음 (hard delete) | UK 충돌 방지, 토글 시 물리 삭제/재생성 | +| Like 생명주기 | BaseEntity 미상속 | 자체 id/createdAt/updatedAt 관리 | soft delete 불필요, 독립적 생명주기 | --- @@ -186,4 +173,4 @@ erDiagram - 삭제 시: `deleted_at = now()` (물리 삭제 없음) - 조회 시: `WHERE deleted_at IS NULL` 조건 사용 - 브랜드 삭제 시: 해당 브랜드의 상품도 `deleted_at` 설정 (cascade soft delete) -- **예외: product_like, brand_like** — 토글 취소 시 hard delete (물리 삭제). UK 충돌 방지를 위해 `deleted_at` 컬럼 없음 +- **예외: likes** — BaseEntity 미상속. 토글 취소 시 hard delete (물리 삭제). UK 충돌 방지를 위해 `deleted_at` 컬럼 없음. 자체 id/createdAt/updatedAt만 관리. diff --git a/gradle.properties b/gradle.properties index 142d7120f..5ae37ac99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 diff --git a/http/commerce-api/address-v1.http b/http/commerce-api/address-v1.http new file mode 100644 index 000000000..0dd26de71 --- /dev/null +++ b/http/commerce-api/address-v1.http @@ -0,0 +1,133 @@ +@baseUrl = http://localhost:8080 + +############################################### +# 배송지 등록 API +############################################### + +### 배송지 등록 (정상 - 첫 등록, isDefault=true) +POST {{baseUrl}}/api/v1/members/me/addresses +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "label": "집", + "recipientName": "홍길동", + "recipientPhone": "010-1234-5678", + "zipCode": "12345", + "address1": "서울시 강남구 테헤란로 123", + "address2": "101동 202호" +} + +### 배송지 등록 (정상 - 두 번째, isDefault=false) +POST {{baseUrl}}/api/v1/members/me/addresses +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "label": "회사", + "recipientName": "홍길동", + "recipientPhone": "010-1234-5678", + "zipCode": "54321", + "address1": "서울시 서초구 서초대로 456", + "address2": null +} + +### 배송지 등록 (필수 필드 누락 - label 없음 - 400) +POST {{baseUrl}}/api/v1/members/me/addresses +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "recipientName": "홍길동", + "recipientPhone": "010-1234-5678", + "zipCode": "12345", + "address1": "서울시 강남구 테헤란로 123" +} + +### 배송지 등록 (인증 없음 - 401) +POST {{baseUrl}}/api/v1/members/me/addresses +Content-Type: application/json + +{ + "label": "집", + "recipientName": "홍길동", + "recipientPhone": "010-1234-5678", + "zipCode": "12345", + "address1": "서울시 강남구 테헤란로 123" +} + +############################################### +# 배송지 목록 조회 API +############################################### + +### 배송지 목록 조회 (정상) +GET {{baseUrl}}/api/v1/members/me/addresses +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 배송지 목록 조회 (인증 없음 - 401) +GET {{baseUrl}}/api/v1/members/me/addresses + +############################################### +# 배송지 수정 API +############################################### + +### 배송지 수정 (정상 - addressId를 실제 값으로 교체) +PUT {{baseUrl}}/api/v1/members/me/addresses/1 +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "label": "새집", + "recipientName": "홍길동", + "recipientPhone": "010-9999-9999", + "zipCode": "67890", + "address1": "서울시 송파구 올림픽로 789", + "address2": "301동 402호" +} + +### 배송지 수정 (존재하지 않는 배송지 - 404) +PUT {{baseUrl}}/api/v1/members/me/addresses/9999 +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "label": "새집", + "recipientName": "홍길동", + "recipientPhone": "010-9999-9999", + "zipCode": "67890", + "address1": "서울시 송파구 올림픽로 789" +} + +############################################### +# 배송지 삭제 API +############################################### + +### 배송지 삭제 (정상 - 비기본 배송지, addressId를 실제 값으로 교체) +DELETE {{baseUrl}}/api/v1/members/me/addresses/2 +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 배송지 삭제 (기본 배송지 삭제 시도 - 400) +DELETE {{baseUrl}}/api/v1/members/me/addresses/1 +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +############################################### +# 기본 배송지 설정 API +############################################### + +### 기본 배송지 설정 (정상 - addressId를 실제 값으로 교체) +PATCH {{baseUrl}}/api/v1/members/me/addresses/2/default +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 기본 배송지 설정 (존재하지 않는 배송지 - 404) +PATCH {{baseUrl}}/api/v1/members/me/addresses/9999/default +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! diff --git a/http/commerce-api/brand-v1.http b/http/commerce-api/brand-v1.http new file mode 100644 index 000000000..65ed7dd1a --- /dev/null +++ b/http/commerce-api/brand-v1.http @@ -0,0 +1,96 @@ +@baseUrl = http://localhost:8080 + +############################################### +# 브랜드 등록 API (관리자) +############################################### + +### 브랜드 등록 (정상) +POST {{baseUrl}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": "Nike", + "description": "Just Do It" +} + +### 브랜드 등록 (설명 없이) +POST {{baseUrl}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": "Adidas", + "description": null +} + +### 브랜드 등록 (이름 빈 문자열 - 400) +POST {{baseUrl}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": " ", + "description": "설명" +} + +### 브랜드 등록 (중복 이름 - 위 Nike 등록 후 실행 - 400) +POST {{baseUrl}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": "Nike", + "description": "다른 설명" +} + +############################################### +# 브랜드 목록 조회 API (공개) +############################################### + +### 브랜드 목록 조회 (전체) +GET {{baseUrl}}/api/v1/brands + +### 브랜드 목록 조회 (키워드 검색) +GET {{baseUrl}}/api/v1/brands?keyword=Nik + +### 브랜드 목록 조회 (페이지네이션) +GET {{baseUrl}}/api/v1/brands?page=0&size=10 + +############################################### +# 브랜드 상세 조회 API (공개) +############################################### + +### 브랜드 상세 조회 (정상 - brandId를 등록 응답에서 확인 후 사용) +GET {{baseUrl}}/api/v1/brands/1 + +### 브랜드 상세 조회 (존재하지 않는 ID - 404) +GET {{baseUrl}}/api/v1/brands/999 + +############################################### +# 브랜드 수정 API (관리자) +############################################### + +### 브랜드 수정 (정상) +PUT {{baseUrl}}/api-admin/v1/brands/1 +Content-Type: application/json + +{ + "name": "Nike Updated", + "description": "Updated Description" +} + +### 브랜드 수정 (존재하지 않는 ID - 404) +PUT {{baseUrl}}/api-admin/v1/brands/999 +Content-Type: application/json + +{ + "name": "Test", + "description": "Test" +} + +############################################### +# 브랜드 삭제 API (관리자) +############################################### + +### 브랜드 삭제 (정상) +DELETE {{baseUrl}}/api-admin/v1/brands/1 + +### 브랜드 삭제 (존재하지 않는 ID - 404) +DELETE {{baseUrl}}/api-admin/v1/brands/999 diff --git a/http/commerce-api/like-v1.http b/http/commerce-api/like-v1.http new file mode 100644 index 000000000..091249f91 --- /dev/null +++ b/http/commerce-api/like-v1.http @@ -0,0 +1,79 @@ +@baseUrl = http://localhost:8080 + +############################################### +# 상품 좋아요 토글 API +############################################### + +### 상품 좋아요 토글 (정상 - 첫 좋아요 → liked=true) +POST {{baseUrl}}/api/v1/products/1/likes +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 상품 좋아요 토글 (정상 - 다시 토글 → liked=false) +POST {{baseUrl}}/api/v1/products/1/likes +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 상품 좋아요 토글 (존재하지 않는 상품 - 404) +POST {{baseUrl}}/api/v1/products/9999/likes +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 상품 좋아요 토글 (인증 없음 - 401) +POST {{baseUrl}}/api/v1/products/1/likes + +############################################### +# 브랜드 좋아요 토글 API +############################################### + +### 브랜드 좋아요 토글 (정상 - 첫 좋아요 → liked=true) +POST {{baseUrl}}/api/v1/brands/1/likes +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 브랜드 좋아요 토글 (정상 - 다시 토글 → liked=false) +POST {{baseUrl}}/api/v1/brands/1/likes +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 브랜드 좋아요 토글 (존재하지 않는 브랜드 - 404) +POST {{baseUrl}}/api/v1/brands/9999/likes +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 브랜드 좋아요 토글 (인증 없음 - 401) +POST {{baseUrl}}/api/v1/brands/1/likes + +############################################### +# 내 상품 좋아요 목록 조회 API +############################################### + +### 내 상품 좋아요 목록 조회 (정상) +GET {{baseUrl}}/api/v1/members/me/likes/products +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 내 상품 좋아요 목록 조회 (페이징) +GET {{baseUrl}}/api/v1/members/me/likes/products?page=0&size=10 +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 내 상품 좋아요 목록 조회 (인증 없음 - 401) +GET {{baseUrl}}/api/v1/members/me/likes/products + +############################################### +# 내 브랜드 좋아요 목록 조회 API +############################################### + +### 내 브랜드 좋아요 목록 조회 (정상) +GET {{baseUrl}}/api/v1/members/me/likes/brands +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 내 브랜드 좋아요 목록 조회 (페이징) +GET {{baseUrl}}/api/v1/members/me/likes/brands?page=0&size=10 +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 내 브랜드 좋아요 목록 조회 (인증 없음 - 401) +GET {{baseUrl}}/api/v1/members/me/likes/brands diff --git a/http/commerce-api/member-v1.http b/http/commerce-api/member-v1.http index d03713578..299e77921 100644 --- a/http/commerce-api/member-v1.http +++ b/http/commerce-api/member-v1.http @@ -9,7 +9,22 @@ Content-Type: application/json "password": "Test1234!", "name": "홍길동", "birthDate": "1990-01-15", - "email": "test@example.com" + "gender": "MALE", + "email": "test@example.com", + "phone": "010-1234-5678" +} + +### 회원가입 (전화번호 없이 - 정상) +POST {{baseUrl}}/api/v1/members +Content-Type: application/json + +{ + "loginId": "testUser2", + "password": "Test1234!", + "name": "김영희", + "birthDate": "1992-03-20", + "gender": "FEMALE", + "email": "test2@example.com" } ### 회원가입 (중복 ID 테스트 - 위 요청 먼저 실행 후) @@ -21,6 +36,7 @@ Content-Type: application/json "password": "Test5678!", "name": "김철수", "birthDate": "1985-05-20", + "gender": "MALE", "email": "second@example.com" } @@ -29,10 +45,11 @@ POST {{baseUrl}}/api/v1/members Content-Type: application/json { - "loginId": "testUser2", + "loginId": "testUser3", "password": "Test1234!", "name": "홍길동", "birthDate": "1990-01-15", + "gender": "MALE", "email": "invalid-email" } @@ -41,11 +58,12 @@ POST {{baseUrl}}/api/v1/members Content-Type: application/json { - "loginId": "testUser3", + "loginId": "testUser4", "password": "short", "name": "홍길동", "birthDate": "1990-01-15", - "email": "test3@example.com" + "gender": "MALE", + "email": "test4@example.com" } ############################################### @@ -112,3 +130,100 @@ X-Loopers-LoginPw: Test1234! "currentPassword": "Test1234!", "newPassword": "short" } + +############################################### +# 전화번호 수정 API +############################################### + +### 전화번호 수정 (정상) +PATCH {{baseUrl}}/api/v1/members/me +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "phone": "010-9999-8888" +} + +### 전화번호 수정 (잘못된 형식 - 400) +PATCH {{baseUrl}}/api/v1/members/me +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "phone": "01012345678" +} + +### 전화번호 제거 (null로 설정 - 정상) +PATCH {{baseUrl}}/api/v1/members/me +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "phone": null +} + +############################################### +# 회원 탈퇴 API +############################################### + +### 회원 탈퇴 (정상 - 회원가입 먼저 실행 후) +DELETE {{baseUrl}}/api/v1/members/me +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "password": "Test1234!" +} + +### 회원 탈퇴 (비밀번호 불일치 - 400) +DELETE {{baseUrl}}/api/v1/members/me +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "password": "WrongPass1!" +} + +############################################### +# Admin: 회원 목록 조회 API (AD-8) +############################################### + +### 회원 목록 조회 (전체) +GET {{baseUrl}}/api-admin/v1/members?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 회원 목록 조회 (keyword - loginId 검색) +GET {{baseUrl}}/api-admin/v1/members?keyword=testUser&page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 회원 목록 조회 (keyword - 이름 검색) +GET {{baseUrl}}/api-admin/v1/members?keyword=홍길동&page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 회원 목록 조회 (keyword - 이메일 검색) +GET {{baseUrl}}/api-admin/v1/members?keyword=example.com&page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 회원 목록 조회 (매칭 없음) +GET {{baseUrl}}/api-admin/v1/members?keyword=nonexistent&page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 회원 목록 조회 (인증 없음 - 401) +GET {{baseUrl}}/api-admin/v1/members?page=0&size=20 + +############################################### +# Admin: 회원 상세 조회 API (AD-9) +############################################### + +### 회원 상세 조회 (정상 - 마스킹 없음) +GET {{baseUrl}}/api-admin/v1/members/1 +X-Loopers-Ldap: loopers.admin + +### 회원 상세 조회 (존재하지 않는 회원 - 404) +GET {{baseUrl}}/api-admin/v1/members/9999 +X-Loopers-Ldap: loopers.admin diff --git a/http/commerce-api/order-v1.http b/http/commerce-api/order-v1.http new file mode 100644 index 000000000..d50692147 --- /dev/null +++ b/http/commerce-api/order-v1.http @@ -0,0 +1,162 @@ +@baseUrl = http://localhost:8080 + +############################################### +# 주문 생성 API +############################################### + +### 주문 생성 (정상 - 단일 상품) +POST {{baseUrl}}/api/v1/orders +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "addressId": 1, + "items": [ + {"productId": 1, "quantity": 2} + ] +} + +### 주문 생성 (정상 - 다수 상품) +POST {{baseUrl}}/api/v1/orders +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "addressId": 1, + "items": [ + {"productId": 1, "quantity": 1}, + {"productId": 2, "quantity": 2} + ] +} + +### 주문 생성 (재고 부족 - 400) +POST {{baseUrl}}/api/v1/orders +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "addressId": 1, + "items": [ + {"productId": 1, "quantity": 9999} + ] +} + +### 주문 생성 (존재하지 않는 배송지 - 400) +POST {{baseUrl}}/api/v1/orders +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "addressId": 9999, + "items": [ + {"productId": 1, "quantity": 1} + ] +} + +### 주문 생성 (빈 items - 400) +POST {{baseUrl}}/api/v1/orders +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "addressId": 1, + "items": [] +} + +### 주문 생성 (인증 없음 - 401) +POST {{baseUrl}}/api/v1/orders +Content-Type: application/json + +{ + "addressId": 1, + "items": [ + {"productId": 1, "quantity": 1} + ] +} + +############################################### +# 내 주문 목록 조회 API +############################################### + +### 내 주문 목록 조회 (정상 - 날짜 범위) +GET {{baseUrl}}/api/v1/orders?startAt=2026-01-01&endAt=2026-12-31&page=0&size=20 +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 내 주문 목록 조회 (인증 없음 - 401) +GET {{baseUrl}}/api/v1/orders?startAt=2026-01-01&endAt=2026-12-31&page=0&size=20 + +############################################### +# 주문 상세 조회 API +############################################### + +### 주문 상세 조회 (정상) +GET {{baseUrl}}/api/v1/orders/1 +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 주문 상세 조회 (존재하지 않는 주문 - 404) +GET {{baseUrl}}/api/v1/orders/9999 +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +############################################### +# 주문 취소 API +############################################### + +### 주문 취소 (정상) +POST {{baseUrl}}/api/v1/orders/1/cancel +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +### 주문 취소 (이미 취소된 주문 - 404) +POST {{baseUrl}}/api/v1/orders/1/cancel +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +############################################### +# 배송지 수정 API +############################################### + +### 배송지 수정 (정상) +PUT {{baseUrl}}/api/v1/orders/1/shipping-address +Content-Type: application/json +X-Loopers-LoginId: testUser1 +X-Loopers-LoginPw: Test1234! + +{ + "recipientName": "김철수", + "recipientPhone": "010-9999-9999", + "zipCode": "54321", + "address1": "서울시 서초구", + "address2": "202호" +} + +############################################### +# Admin - 주문 목록 조회 API +############################################### + +### Admin 주문 목록 조회 (전체) +GET {{baseUrl}}/api-admin/v1/orders?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### Admin 주문 목록 조회 (memberId 필터) +GET {{baseUrl}}/api-admin/v1/orders?memberId=1&page=0&size=20 +X-Loopers-Ldap: loopers.admin + +############################################### +# Admin - 주문 상세 조회 API +############################################### + +### Admin 주문 상세 조회 +GET {{baseUrl}}/api-admin/v1/orders/1 +X-Loopers-Ldap: loopers.admin + +### Admin 주문 상세 조회 (존재하지 않는 주문 - 404) +GET {{baseUrl}}/api-admin/v1/orders/9999 +X-Loopers-Ldap: loopers.admin diff --git a/http/commerce-api/product-v1.http b/http/commerce-api/product-v1.http new file mode 100644 index 000000000..d7a623e8e --- /dev/null +++ b/http/commerce-api/product-v1.http @@ -0,0 +1,196 @@ +@baseUrl = http://localhost:8080 + +############################################### +# 사전 준비: 브랜드 등록 +############################################### + +### 브랜드 등록 (상품 등록 전 필요) +POST {{baseUrl}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": "Nike", + "description": "Just Do It" +} + +############################################### +# 상품 등록 API (관리자) +############################################### + +### 상품 등록 (정상) +POST {{baseUrl}}/api-admin/v1/products +Content-Type: application/json + +{ + "brandId": 1, + "name": "에어맥스 90", + "description": "클래식 러닝화", + "price": 179000, + "stockQuantity": 100, + "maxOrderQuantity": 5 +} + +### 상품 등록 (설명 없이) +POST {{baseUrl}}/api-admin/v1/products +Content-Type: application/json + +{ + "brandId": 1, + "name": "에어포스 1", + "description": null, + "price": 139000, + "stockQuantity": 50, + "maxOrderQuantity": 3 +} + +### 상품 등록 (존재하지 않는 브랜드 - 400) +POST {{baseUrl}}/api-admin/v1/products +Content-Type: application/json + +{ + "brandId": 999, + "name": "테스트 상품", + "description": "설명", + "price": 10000, + "stockQuantity": 10, + "maxOrderQuantity": 1 +} + +### 상품 등록 (이름 빈 문자열 - 400) +POST {{baseUrl}}/api-admin/v1/products +Content-Type: application/json + +{ + "brandId": 1, + "name": " ", + "description": "설명", + "price": 10000, + "stockQuantity": 10, + "maxOrderQuantity": 1 +} + +### 상품 등록 (가격 0 - 400) +POST {{baseUrl}}/api-admin/v1/products +Content-Type: application/json + +{ + "brandId": 1, + "name": "무료 상품", + "description": "설명", + "price": 0, + "stockQuantity": 10, + "maxOrderQuantity": 1 +} + +############################################### +# 상품 목록 조회 API (공개) +############################################### + +### 상품 목록 조회 (전체) +GET {{baseUrl}}/api/v1/products + +### 상품 목록 조회 (키워드 검색 - 상품명) +GET {{baseUrl}}/api/v1/products?keyword=에어맥스 + +### 상품 목록 조회 (키워드 검색 - 브랜드명) +GET {{baseUrl}}/api/v1/products?keyword=Nike + +### 상품 목록 조회 (브랜드 필터) +GET {{baseUrl}}/api/v1/products?brandId=1 + +### 상품 목록 조회 (가격 오름차순 정렬) +GET {{baseUrl}}/api/v1/products?sort=PRICE_ASC + +### 상품 목록 조회 (가격 내림차순 정렬) +GET {{baseUrl}}/api/v1/products?sort=PRICE_DESC + +### 상품 목록 조회 (좋아요 내림차순 정렬) +GET {{baseUrl}}/api/v1/products?sort=LIKES_DESC + +### 상품 목록 조회 (페이지네이션) +GET {{baseUrl}}/api/v1/products?page=0&size=10 + +### 상품 목록 조회 (복합 조건) +GET {{baseUrl}}/api/v1/products?keyword=에어&brandId=1&sort=PRICE_ASC&page=0&size=5 + +############################################### +# 상품 상세 조회 API (공개) +############################################### + +### 상품 상세 조회 (정상 - 브랜드 정보 포함) +GET {{baseUrl}}/api/v1/products/1 + +### 상품 상세 조회 (존재하지 않는 ID - 404) +GET {{baseUrl}}/api/v1/products/999 + +############################################### +# 상품 수정 API (관리자) +############################################### + +### 상품 수정 (정상) +PUT {{baseUrl}}/api-admin/v1/products/1 +Content-Type: application/json + +{ + "name": "에어맥스 90 리뉴얼", + "description": "리뉴얼된 클래식 러닝화", + "price": 189000, + "maxOrderQuantity": 3 +} + +### 상품 수정 (존재하지 않는 ID - 404) +PUT {{baseUrl}}/api-admin/v1/products/999 +Content-Type: application/json + +{ + "name": "테스트", + "description": "테스트", + "price": 10000, + "maxOrderQuantity": 1 +} + +############################################### +# 재고 수정 API (관리자) +############################################### + +### 재고 수정 (정상) +PATCH {{baseUrl}}/api-admin/v1/products/1/stock +Content-Type: application/json + +{ + "quantity": 200 +} + +### 재고 수정 (0으로 설정) +PATCH {{baseUrl}}/api-admin/v1/products/1/stock +Content-Type: application/json + +{ + "quantity": 0 +} + +### 재고 수정 (음수 - 400) +PATCH {{baseUrl}}/api-admin/v1/products/1/stock +Content-Type: application/json + +{ + "quantity": -1 +} + +### 재고 수정 (존재하지 않는 ID - 404) +PATCH {{baseUrl}}/api-admin/v1/products/999/stock +Content-Type: application/json + +{ + "quantity": 10 +} + +############################################### +# 상품 삭제 API (관리자) +############################################### + +### 상품 삭제 (정상) +DELETE {{baseUrl}}/api-admin/v1/products/1 + +### 상품 삭제 (존재하지 않는 ID - 404) +DELETE {{baseUrl}}/api-admin/v1/products/999