diff --git a/src/docs/asciidoc/_cart.adoc b/src/docs/asciidoc/_cart.adoc new file mode 100644 index 00000000..a302f9c3 --- /dev/null +++ b/src/docs/asciidoc/_cart.adoc @@ -0,0 +1,46 @@ +== 장바구니 (Cart) API + +=== 1. 장바구니 추가 +`POST /carts` + +요청 본문: +include::{snippets}/cart-create/request-fields.adoc[] + +응답: +include::{snippets}/cart-create/http-response.adoc[] + +--- + +=== 2. 장바구니 목록 조회 +`GET /carts` + +응답 본문: +include::{snippets}/cart-get-list/response-fields.adoc[] + +응답: +include::{snippets}/cart-get-list/http-response.adoc[] + +--- + +=== 3. 장바구니 수량 수정 +`PATCH /carts/{cartId}` + +경로 파라미터 (Path Parameters): +include::{snippets}/cart-update/path-parameters.adoc[] + +요청 본문: +include::{snippets}/cart-update/request-fields.adoc[] + +응답: +include::{snippets}/cart-update/http-response.adoc[] + +--- + +=== 4. 장바구니 삭제 +`DELETE /carts/{cartId}` + +경로 파라미터 (Path Parameters): +include::{snippets}/cart-delete/path-parameters.adoc[] + +응답: +include::{snippets}/cart-delete/http-response.adoc[] diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 1e373272..52755f6d 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -23,3 +23,5 @@ include::_owner_order.adoc[] // include::_product.adoc[] include::_product_image.adoc[] +include::_cart.adoc[] + diff --git a/src/main/java/com/irum/come2us/domain/cart/application/service/CartService.java b/src/main/java/com/irum/come2us/domain/cart/application/service/CartService.java index a006a5f8..c1866923 100644 --- a/src/main/java/com/irum/come2us/domain/cart/application/service/CartService.java +++ b/src/main/java/com/irum/come2us/domain/cart/application/service/CartService.java @@ -15,86 +15,59 @@ import java.util.UUID; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @Transactional -@Slf4j public class CartService { private final CartRepository cartRepository; private final ProductOptionValueRepository productOptionValueRepository; private final MemberUtil memberUtil; + @Transactional public CartResponse createCart(CartCreateRequest request) { Member currentMember = memberUtil.getCurrentMember(); - // 옵션값 검증 ProductOptionValue optionValue = productOptionValueRepository .findById(request.optionValueId()) .orElseThrow( () -> new CommonException(CartErrorCode.OPTION_VALUE_NOT_FOUND)); - // 기존 동일 옵션 Cart 존재 시 수량 합산 Cart existing = cartRepository.findByMemberIdAndOptionValueId( currentMember.getMemberId(), request.optionValueId()); - Cart saved; + Cart target; if (existing != null) { - int oldQuantity = existing.getQuantity(); - int updatedQuantity = oldQuantity + request.quantity(); + int updatedQuantity = existing.getQuantity() + request.quantity(); existing.updateQuantity(updatedQuantity); - saved = existing; - - log.info( - "장바구니 수량 합산: memberId={}, optionValueId={}, oldQuantity={}, newQuantity={}", - currentMember.getMemberId(), - request.optionValueId(), - oldQuantity, - updatedQuantity); + target = existing; } else { - Cart newCart = Cart.createCart(currentMember, optionValue, request.quantity()); - saved = cartRepository.save(newCart); - - log.info( - "장바구니 신규 추가: memberId={}, optionValueId={}, quantity={}", - currentMember.getMemberId(), - request.optionValueId(), - request.quantity()); + target = Cart.createCart(currentMember, optionValue, request.quantity()); + cartRepository.save(target); } - return CartResponse.from(saved); + return CartResponse.from(target); } - public CartResponse updateCart(UUID cartId, CartUpdateRequest request) { + public void updateCart(UUID cartId, CartUpdateRequest request) { Cart cart = cartRepository .findById(cartId) .orElseThrow(() -> new CommonException(CartErrorCode.CART_NOT_FOUND)); memberUtil.assertMemberResourceAccess(cart.getMember()); - - if (cart.getQuantity().equals(request.quantity())) { - throw new CommonException(CartErrorCode.CART_NOT_MODIFIED); - } - cart.updateQuantity(request.quantity()); - log.info("장바구니 수정 완료: cartId={}, updatedQuantity={}", cartId, request.quantity()); - return CartResponse.from(cart); } @Transactional(readOnly = true) public List getCartListByMember() { Member currentMember = memberUtil.getCurrentMember(); - List carts = cartRepository.findAllWithProductByMemberId(currentMember.getMemberId()); - log.info("장바구니 조회 완료: memberId={}, count={}", currentMember.getMemberId(), carts.size()); - return carts.stream().map(CartResponse::from).collect(Collectors.toList()); } @@ -103,10 +76,7 @@ public void deleteCart(UUID cartId) { cartRepository .findById(cartId) .orElseThrow(() -> new CommonException(CartErrorCode.CART_NOT_FOUND)); - memberUtil.assertMemberResourceAccess(cart.getMember()); cartRepository.delete(cart); - - log.info("장바구니 삭제 완료: cartId={}", cartId); } } diff --git a/src/main/java/com/irum/come2us/domain/cart/domain/repository/CartRepository.java b/src/main/java/com/irum/come2us/domain/cart/domain/repository/CartRepository.java index 669d8482..4b2aa0d7 100644 --- a/src/main/java/com/irum/come2us/domain/cart/domain/repository/CartRepository.java +++ b/src/main/java/com/irum/come2us/domain/cart/domain/repository/CartRepository.java @@ -23,8 +23,10 @@ public interface CartRepository extends JpaRepository { Cart findByMemberIdAndOptionValueId( @Param("memberId") @NotNull Long memberId, @Param("optionValueId") UUID optionValueId); - // 특정 회원의 장바구니 + 상품/옵션 통합 조회 - // TODO: 이미지 조인 추후 추가 예정 + /** + * 특정 회원의 장바구니 + 상품/옵션/이미지 통합 조회 - Cart → OptionValue → OptionGroup → Product → ProductImage - + * LEFT JOIN FETCH 로 대표 이미지까지 미리 로딩 + */ @Query( """ SELECT DISTINCT c @@ -32,6 +34,7 @@ Cart findByMemberIdAndOptionValueId( JOIN FETCH c.optionValue ov JOIN FETCH ov.optionGroup og JOIN FETCH og.product p + LEFT JOIN FETCH p.productImages pi WHERE c.member.memberId = :memberId """) List findAllWithProductByMemberId(@Param("memberId") Long memberId); diff --git a/src/main/java/com/irum/come2us/domain/cart/presentation/controller/CartController.java b/src/main/java/com/irum/come2us/domain/cart/presentation/controller/CartController.java index 2c88e090..6e87342d 100644 --- a/src/main/java/com/irum/come2us/domain/cart/presentation/controller/CartController.java +++ b/src/main/java/com/irum/come2us/domain/cart/presentation/controller/CartController.java @@ -9,6 +9,8 @@ import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @RestController @@ -20,30 +22,35 @@ public class CartController { private final CartService cartService; @PostMapping - public CartResponse createCart(@Valid @RequestBody CartCreateRequest request) { + public ResponseEntity createCart(@Valid @RequestBody CartCreateRequest request) { log.info( "장바구니 추가 요청: optionValueId={}, quantity={}", request.optionValueId(), request.quantity()); - return cartService.createCart(request); + CartResponse response = cartService.createCart(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); } @PatchMapping("/{cartId}") - public CartResponse updateCart( + public ResponseEntity updateCart( @PathVariable UUID cartId, @Valid @RequestBody CartUpdateRequest request) { log.info("장바구니 수정 요청: cartId={}, quantity={}", cartId, request.quantity()); - return cartService.updateCart(cartId, request); + cartService.updateCart(cartId, request); + return ResponseEntity.noContent().build(); } @GetMapping - public List getMyCartList() { - log.info("본인 장바구니 조회 요청"); - return cartService.getCartListByMember(); + public ResponseEntity> getMyCartList() { + log.info("장바구니 목록 조회 요청"); + List responses = cartService.getCartListByMember(); + log.info("장바구니 조회 결과: {}개 항목", responses.size()); + return ResponseEntity.ok(responses); } @DeleteMapping("/{cartId}") - public void deleteCart(@PathVariable UUID cartId) { + public ResponseEntity deleteCart(@PathVariable UUID cartId) { log.info("장바구니 삭제 요청: cartId={}", cartId); cartService.deleteCart(cartId); + return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/irum/come2us/domain/cart/presentation/dto/request/CartCreateRequest.java b/src/main/java/com/irum/come2us/domain/cart/presentation/dto/request/CartCreateRequest.java index 77fb200e..3ebfc505 100644 --- a/src/main/java/com/irum/come2us/domain/cart/presentation/dto/request/CartCreateRequest.java +++ b/src/main/java/com/irum/come2us/domain/cart/presentation/dto/request/CartCreateRequest.java @@ -5,6 +5,5 @@ import java.util.UUID; public record CartCreateRequest( - @NotNull(message = "회원 ID는 필수 입력값입니다.") Long memberId, @NotNull(message = "옵션 값 ID는 필수 입력값입니다.") UUID optionValueId, @Min(value = 1, message = "수량은 1개 이상이어야 합니다.") int quantity) {} diff --git a/src/main/java/com/irum/come2us/domain/cart/presentation/dto/response/CartResponse.java b/src/main/java/com/irum/come2us/domain/cart/presentation/dto/response/CartResponse.java index 78c60942..cee81acd 100644 --- a/src/main/java/com/irum/come2us/domain/cart/presentation/dto/response/CartResponse.java +++ b/src/main/java/com/irum/come2us/domain/cart/presentation/dto/response/CartResponse.java @@ -10,7 +10,7 @@ public record CartResponse( UUID optionValueId, String productName, String optionValueName, - // String imageUrl, // 상품 이미지 구현 후 주석 해제 + String imageUrl, int quantity, int basePrice, // 상품 기본가 int extraPrice, // 옵션 추가금 @@ -26,17 +26,17 @@ public static CartResponse from(Cart cart) { int unit = base + extra; int total = unit * cart.getQuantity(); - // String imageUrl = (product.getProductImages() != null && - // !product.getProductImages().isEmpty()) - // ? product.getProductImages().get(0).getImageUrl() - // : null; + String imageUrl = + (product.getProductImages() != null && !product.getProductImages().isEmpty()) + ? product.getProductImages().get(0).getImageUrl() + : null; return CartResponse.builder() .cartId(cart.getId()) .optionValueId(optionValue.getId()) .productName(product.getName()) .optionValueName(optionValue.getName()) - // .imageUrl(imageUrl) + .imageUrl(imageUrl) .quantity(cart.getQuantity()) .basePrice(base) .extraPrice(extra) diff --git a/src/main/java/com/irum/come2us/domain/product/domain/entity/Product.java b/src/main/java/com/irum/come2us/domain/product/domain/entity/Product.java index bba92469..290a718a 100644 --- a/src/main/java/com/irum/come2us/domain/product/domain/entity/Product.java +++ b/src/main/java/com/irum/come2us/domain/product/domain/entity/Product.java @@ -132,5 +132,8 @@ public void updateCategory(Category category) { this.category = category; } - // TODO: 이미지 매핑, 리뷰 매핑 + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + private List productImages = new ArrayList<>(); + + // TODO: 리뷰 매핑 } diff --git a/src/test/java/com/irum/come2us/domain/cart/CartControllerTest.java b/src/test/java/com/irum/come2us/domain/cart/CartControllerTest.java new file mode 100644 index 00000000..471a7939 --- /dev/null +++ b/src/test/java/com/irum/come2us/domain/cart/CartControllerTest.java @@ -0,0 +1,179 @@ +package com.irum.come2us.domain.cart; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.irum.come2us.domain.cart.application.service.CartService; +import com.irum.come2us.domain.cart.presentation.controller.CartController; +import com.irum.come2us.domain.cart.presentation.dto.request.CartCreateRequest; +import com.irum.come2us.domain.cart.presentation.dto.request.CartUpdateRequest; +import com.irum.come2us.domain.cart.presentation.dto.response.CartResponse; +import com.irum.come2us.global.config.SecurityTestConfig; +import com.irum.come2us.global.config.TestConfig; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(CartController.class) +@AutoConfigureRestDocs +@Import({SecurityTestConfig.class, TestConfig.class}) +class CartControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private CartService cartService; + @Autowired private ObjectMapper objectMapper; + + private final UUID mockCartId = UUID.randomUUID(); + private final UUID mockOptionValueId = UUID.randomUUID(); + + @Test + @DisplayName("장바구니 추가 API (CUSTOMER)") + void createCartApiTest() throws Exception { + CartCreateRequest request = new CartCreateRequest(mockOptionValueId, 2); + String requestJson = objectMapper.writeValueAsString(request); + + CartResponse response = + new CartResponse( + mockCartId, + mockOptionValueId, + "테스트 상품", + "옵션A", + "https://example.com/image.jpg", + 2, + 10000, + 500, + 10500, + 21000); + + when(cartService.createCart(any(CartCreateRequest.class))).thenReturn(response); + + mockMvc.perform( + RestDocumentationRequestBuilders.post("/carts") + .with(csrf()) + .with(user("1").roles("CUSTOMER")) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isCreated()) + .andDo( + document( + "cart-create", + requestFields( + fieldWithPath("optionValueId").description("옵션 값 ID"), + fieldWithPath("quantity").description("상품 수량")), + responseFields( + fieldWithPath("success").description("true"), + fieldWithPath("status").description("201"), + fieldWithPath("timestamp").description("응답 시간"), + fieldWithPath("data.cartId").description("장바구니 ID"), + fieldWithPath("data.optionValueId").description("옵션 값 ID"), + fieldWithPath("data.productName").description("상품 이름"), + fieldWithPath("data.optionValueName").description("옵션 이름"), + fieldWithPath("data.imageUrl").description("대표 이미지 URL"), + fieldWithPath("data.quantity").description("수량"), + fieldWithPath("data.basePrice").description("상품 기본가"), + fieldWithPath("data.extraPrice").description("옵션 추가금"), + fieldWithPath("data.unitPrice").description("단가"), + fieldWithPath("data.lineTotal").description("총 금액")))); + } + + @Test + @DisplayName("장바구니 목록 조회 API (CUSTOMER)") + void getCartListApiTest() throws Exception { + CartResponse response = + new CartResponse( + mockCartId, + mockOptionValueId, + "테스트 상품", + "옵션A", + "https://example.com/image.jpg", + 2, + 10000, + 500, + 10500, + 21000); + + when(cartService.getCartListByMember()).thenReturn(List.of(response)); + + mockMvc.perform( + RestDocumentationRequestBuilders.get("/carts") + .with(csrf().asHeader()) + .with(user("1").roles("CUSTOMER"))) + .andExpect(status().isOk()) + .andDo( + document( + "cart-get-list", + responseFields( + fieldWithPath("success").description("true"), + fieldWithPath("status").description("200"), + fieldWithPath("timestamp").description("응답 시간"), + fieldWithPath("data[].cartId").description("장바구니 ID"), + fieldWithPath("data[].optionValueId") + .description("옵션 값 ID"), + fieldWithPath("data[].productName").description("상품 이름"), + fieldWithPath("data[].optionValueName") + .description("옵션 이름"), + fieldWithPath("data[].imageUrl").description("대표 이미지 URL"), + fieldWithPath("data[].quantity").description("수량"), + fieldWithPath("data[].basePrice").description("상품 기본가"), + fieldWithPath("data[].extraPrice").description("옵션 추가금"), + fieldWithPath("data[].unitPrice").description("단가"), + fieldWithPath("data[].lineTotal").description("총 금액")))); + } + + @Test + @DisplayName("장바구니 수정 API (CUSTOMER)") + void updateCartApiTest() throws Exception { + CartUpdateRequest request = new CartUpdateRequest(5); + String requestJson = objectMapper.writeValueAsString(request); + + doNothing().when(cartService).updateCart(eq(mockCartId), any(CartUpdateRequest.class)); + + mockMvc.perform( + RestDocumentationRequestBuilders.patch("/carts/{cartId}", mockCartId) + .with(csrf()) + .with(user("1").roles("CUSTOMER")) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andExpect(status().isNoContent()) + .andDo( + document( + "cart-update", + pathParameters( + parameterWithName("cartId").description("수정할 장바구니 ID")), + requestFields(fieldWithPath("quantity").description("변경할 수량")))); + } + + @Test + @DisplayName("장바구니 삭제 API (CUSTOMER)") + void deleteCartApiTest() throws Exception { + doNothing().when(cartService).deleteCart(eq(mockCartId)); + + mockMvc.perform( + RestDocumentationRequestBuilders.delete("/carts/{cartId}", mockCartId) + .with(csrf()) + .with(user("1").roles("CUSTOMER"))) + .andExpect(status().isNoContent()) + .andDo( + document( + "cart-delete", + pathParameters( + parameterWithName("cartId").description("삭제할 장바구니 ID")))); + } +} diff --git a/src/test/java/com/irum/come2us/global/config/TestConfig.java b/src/test/java/com/irum/come2us/global/config/TestConfig.java index d1331008..1a193f60 100644 --- a/src/test/java/com/irum/come2us/global/config/TestConfig.java +++ b/src/test/java/com/irum/come2us/global/config/TestConfig.java @@ -2,6 +2,7 @@ import com.irum.come2us.domain.auth.application.service.AuthService; import com.irum.come2us.domain.auth.application.service.JwtTokenService; +import com.irum.come2us.domain.cart.application.service.CartService; import com.irum.come2us.domain.deliveryaddress.application.service.DeliveryAddressService; import com.irum.come2us.domain.discount.application.service.DiscountService; import com.irum.come2us.domain.member.application.service.ManagerService; @@ -59,6 +60,8 @@ public DiscountService discountService() { } @Bean + public CartService cartService() { + return Mockito.mock(CartService.class); public OwnerOrderService ownerOrderService() { return Mockito.mock(OwnerOrderService.class); }