diff --git a/build.gradle.kts b/build.gradle.kts index 10a77c4..49d9922 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,12 +16,15 @@ repositories { } dependencies { + implementation ("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") developmentOnly("org.springframework.boot:spring-boot-devtools") testImplementation("org.springframework.boot:spring-boot-starter-test") + testCompileOnly ("org.projectlombok:lombok") + testAnnotationProcessor ("org.projectlombok:lombok") } tasks.withType { diff --git a/src/main/java/com/example/demo/application/CartService.java b/src/main/java/com/example/demo/application/CartService.java new file mode 100644 index 0000000..c1ed2fc --- /dev/null +++ b/src/main/java/com/example/demo/application/CartService.java @@ -0,0 +1,48 @@ +package com.example.demo.application; + +import com.example.demo.exception.CartNotFoundException; +import com.example.demo.exception.ProductNotFoundException; +import com.example.demo.model.Cart; +import com.example.demo.model.LineItemId; +import com.example.demo.model.ProductId; +import com.example.demo.model.ProductOption; +import com.example.demo.repository.CartRepository; +import com.example.demo.repository.ProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class CartService { + + private final CartRepository cartRepository; + private final ProductRepository productRepository; + + public CartService(CartRepository cartRepository, ProductRepository productRepository) { + this.cartRepository = cartRepository; + this.productRepository = productRepository; + } + + public void addItemToCart(String userId, ProductId productId, ProductOption option, int quantity) { + Cart cart = getCart(userId); + if (!productRepository.existsById(productId)) { + throw new ProductNotFoundException(productId); + } + cart.addProduct(productId, option, quantity); + } + + public void removeLineItem(String userId, LineItemId lineItemId) { + Cart cart = getCart(userId); + cart.removeLineItem(lineItemId); + } + + public void clearCart(String userId) { + Cart cart = getCart(userId); + cart.clearItems(); + } + + public Cart getCart(String userId) { + return cartRepository.findByUserId(userId) + .orElseThrow(CartNotFoundException::new); + } +} diff --git a/src/main/java/com/example/demo/controllers/CartController.java b/src/main/java/com/example/demo/controllers/CartController.java index 0e1469a..4399f13 100644 --- a/src/main/java/com/example/demo/controllers/CartController.java +++ b/src/main/java/com/example/demo/controllers/CartController.java @@ -1,14 +1,65 @@ package com.example.demo.controllers; +import com.example.demo.application.CartService; +import com.example.demo.controllers.dto.CartResponseDto; +import com.example.demo.controllers.dto.LineItemResponseDto; +import com.example.demo.model.Cart; +import com.example.demo.util.AuthorizationUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import static com.example.demo.util.AuthorizationUtils.extractUserIdFromAuthorization; + @RestController @RequestMapping("/cart") public class CartController { + + private final CartService cartService; + + public CartController(CartService cartService) { + this.cartService = cartService; + } + + @GetMapping - String detail() { - return ""; + public ResponseEntity getCart( + @RequestHeader(name = "Authorization", required = false) + String authorization) { + + String userId = extractUserIdFromAuthorization(authorization); + Cart cart = cartService.getCart(userId); + + CartResponseDto responseDto = new CartResponseDto( + cart.getTotalQuantity(), + cart.getLineItems().stream() + .map(lineItem -> new LineItemResponseDto( + lineItem.getProductId().id(), + lineItem.getProductOption().color(), + lineItem.getProductOption().size(), + lineItem.getQuantity() + ) + ).toList() + ); + + return new ResponseEntity<>(responseDto, HttpStatus.OK); + } + + + @DeleteMapping() + public ResponseEntity deleteCart( + @RequestHeader(name = "Authorization", required = false) + String authorization) { + + String userId = extractUserIdFromAuthorization(authorization); + + cartService.clearCart(userId); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); } + + } diff --git a/src/main/java/com/example/demo/controllers/LineItemController.java b/src/main/java/com/example/demo/controllers/LineItemController.java index 20dc170..07feed9 100644 --- a/src/main/java/com/example/demo/controllers/LineItemController.java +++ b/src/main/java/com/example/demo/controllers/LineItemController.java @@ -1,17 +1,62 @@ package com.example.demo.controllers; +import com.example.demo.application.CartService; +import com.example.demo.controllers.dto.LineItemRequestDto; +import com.example.demo.exception.UnauthorizedException; +import com.example.demo.model.Cart; +import com.example.demo.model.LineItemId; +import com.example.demo.model.ProductId; +import com.example.demo.model.ProductOption; +import jakarta.validation.Valid; 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.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import static com.example.demo.util.AuthorizationUtils.extractUserIdFromAuthorization; + + @RestController @RequestMapping("/cart/line-items") public class LineItemController { + + public LineItemController(CartService cartService) { + this.cartService = cartService; + } + + private final CartService cartService; + @PostMapping @ResponseStatus(HttpStatus.CREATED) - void create() { - // + public void create( + @Valid @RequestBody LineItemRequestDto requestDto, + @RequestHeader("Authorization") String authorization) { + + String userId = extractUserIdFromAuthorization(authorization); + + cartService.addItemToCart( + userId, + new ProductId(requestDto.productId()), + new ProductOption(requestDto.color(), requestDto.size()), + requestDto.quantity()); + + } + + @DeleteMapping("/{lineItemId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteLineItem( + @PathVariable String lineItemId, + @RequestHeader("Authorization") String authorization) { + + String userId = extractUserIdFromAuthorization(authorization); + + cartService.removeLineItem( + userId, + new LineItemId(lineItemId)); } } diff --git a/src/main/java/com/example/demo/controllers/dto/CartResponseDto.java b/src/main/java/com/example/demo/controllers/dto/CartResponseDto.java new file mode 100644 index 0000000..29c9ebd --- /dev/null +++ b/src/main/java/com/example/demo/controllers/dto/CartResponseDto.java @@ -0,0 +1,9 @@ +package com.example.demo.controllers.dto; + +import java.util.List; + +public record CartResponseDto( + int totalQuantity, + List lineItems +) { +} diff --git a/src/main/java/com/example/demo/controllers/dto/LineItemRequestDto.java b/src/main/java/com/example/demo/controllers/dto/LineItemRequestDto.java new file mode 100644 index 0000000..466dca0 --- /dev/null +++ b/src/main/java/com/example/demo/controllers/dto/LineItemRequestDto.java @@ -0,0 +1,22 @@ +package com.example.demo.controllers.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; + +public record LineItemRequestDto( + + @NotBlank(message = "상품 ID는 필수입니다.") + String productId, + + @NotBlank(message = "색상은 필수입니다.") + String color, + + @NotBlank(message = "사이즈는 필수입니다.") + String size, + + @Min(value = 1, message = "최소 수량은 1개입니다.") + @Max(value = 20, message = "최대 수량은 20개입니다.") + int quantity +) { +} diff --git a/src/main/java/com/example/demo/controllers/dto/LineItemResponseDto.java b/src/main/java/com/example/demo/controllers/dto/LineItemResponseDto.java new file mode 100644 index 0000000..525fadf --- /dev/null +++ b/src/main/java/com/example/demo/controllers/dto/LineItemResponseDto.java @@ -0,0 +1,10 @@ +package com.example.demo.controllers.dto; + +public record LineItemResponseDto( + String productId, + String color, + String size, + int quantity +) { + +} diff --git a/src/main/java/com/example/demo/exception/CartNotFoundException.java b/src/main/java/com/example/demo/exception/CartNotFoundException.java new file mode 100644 index 0000000..0cf6931 --- /dev/null +++ b/src/main/java/com/example/demo/exception/CartNotFoundException.java @@ -0,0 +1,7 @@ +package com.example.demo.exception; + +public class CartNotFoundException extends RuntimeException { + public CartNotFoundException() { + super("장바구니를 찾을 수 없습니다."); + } +} diff --git a/src/main/java/com/example/demo/exception/CartQuantityLimitException.java b/src/main/java/com/example/demo/exception/CartQuantityLimitException.java new file mode 100644 index 0000000..31cf94c --- /dev/null +++ b/src/main/java/com/example/demo/exception/CartQuantityLimitException.java @@ -0,0 +1,7 @@ +package com.example.demo.exception; + +public class CartQuantityLimitException extends RuntimeException { + public CartQuantityLimitException() { + super("장바구니에 담을 수 있는 수량을 초과했습니다."); + } +} diff --git a/src/main/java/com/example/demo/exception/ErrorResponseDto.java b/src/main/java/com/example/demo/exception/ErrorResponseDto.java new file mode 100644 index 0000000..da5bc94 --- /dev/null +++ b/src/main/java/com/example/demo/exception/ErrorResponseDto.java @@ -0,0 +1,8 @@ +package com.example.demo.exception; + + +public record ErrorResponseDto( + String code, + String message +) { +} diff --git a/src/main/java/com/example/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/example/demo/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..64d7995 --- /dev/null +++ b/src/main/java/com/example/demo/exception/GlobalExceptionHandler.java @@ -0,0 +1,40 @@ +package com.example.demo.exception; + + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CartNotFoundException.class) + public ResponseEntity handleCartNotFound(CartNotFoundException e) { + ErrorResponseDto responseDto = new ErrorResponseDto("CART_NOT_FOUND", e.getMessage()); + return new ResponseEntity<>(responseDto, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(ProductNotFoundException.class) + public ResponseEntity handleProductNotFound(ProductNotFoundException e) { + ErrorResponseDto responseDto = new ErrorResponseDto("PRODUCT_NOT_FOUND", e.getMessage()); + return new ResponseEntity<>(responseDto, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(CartQuantityLimitException.class) + public ResponseEntity handleCartQuantityLimit(CartQuantityLimitException e) { + ErrorResponseDto responseDto = new ErrorResponseDto("CART_QUANTITY_LIMIT", e.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .contentType(MediaType.APPLICATION_JSON) + .body(responseDto); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorized(UnauthorizedException e) { + ErrorResponseDto responseDto = new ErrorResponseDto("UNAUTHORIZED", e.getMessage()); + return new ResponseEntity<>(responseDto, HttpStatus.UNAUTHORIZED); + } + +} diff --git a/src/main/java/com/example/demo/exception/ProductNotFoundException.java b/src/main/java/com/example/demo/exception/ProductNotFoundException.java new file mode 100644 index 0000000..7cd79be --- /dev/null +++ b/src/main/java/com/example/demo/exception/ProductNotFoundException.java @@ -0,0 +1,9 @@ +package com.example.demo.exception; + +import com.example.demo.model.ProductId; + +public class ProductNotFoundException extends RuntimeException { + public ProductNotFoundException(ProductId productId) { + super("상품을 찾을 수 없습니다. ID: " + productId.id()); + } +} diff --git a/src/main/java/com/example/demo/exception/UnauthorizedException.java b/src/main/java/com/example/demo/exception/UnauthorizedException.java new file mode 100644 index 0000000..c949d60 --- /dev/null +++ b/src/main/java/com/example/demo/exception/UnauthorizedException.java @@ -0,0 +1,7 @@ +package com.example.demo.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException() { + super("인증이 필요합니다."); + } +} diff --git a/src/main/java/com/example/demo/model/Cart.java b/src/main/java/com/example/demo/model/Cart.java new file mode 100644 index 0000000..7ff7ecb --- /dev/null +++ b/src/main/java/com/example/demo/model/Cart.java @@ -0,0 +1,101 @@ +package com.example.demo.model; + +import com.example.demo.exception.CartQuantityLimitException; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Entity +@Table(name="carts") +public class Cart { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String userId; + + @OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true) + private List lineItems = new ArrayList<>(); + + private int totalQuantity; + + public Cart() { + } + + public Cart(String userId) { + this.userId = userId; + } + + public Cart(String userId, List lineItems) { + this.userId = userId; + this.lineItems = new ArrayList<>(lineItems); + updateTotalQuantity(); + } + + public Long getId() { + return id; + } + + public List getLineItems() { + return Collections.unmodifiableList(lineItems); + } + + public int getTotalQuantity() { + return totalQuantity; + } + + public void addProduct(ProductId productId, ProductOption productOption, int quantity) { + + LineItem lineItem = findLineItem(productId, productOption); + + if (lineItem != null) { + lineItem.addQuantity(quantity); + updateTotalQuantity(); + return; + } + + lineItem = new LineItem(productId, productOption, quantity); + lineItems.add(lineItem); + updateTotalQuantity(); + } + + + private void updateTotalQuantity() { + this.totalQuantity = lineItems.stream() + .mapToInt(LineItem::getQuantity) + .sum(); + if (totalQuantity > 20) { + throw new CartQuantityLimitException(); + } + } + + + private LineItem findLineItem(ProductId productId, ProductOption productOption) { + return lineItems.stream() + .filter(lineItem -> + lineItem.isSameProduct(productId,productOption)) + .findFirst() + .orElse(null); + } + + public void clearItems() { + lineItems.clear(); + updateTotalQuantity(); + } + + public void removeLineItem(LineItemId lineItemId) { + lineItems.removeIf(lineItem ->lineItem.getId().equals(lineItemId)); + updateTotalQuantity(); + } +} diff --git a/src/main/java/com/example/demo/model/LineItem.java b/src/main/java/com/example/demo/model/LineItem.java new file mode 100644 index 0000000..5734100 --- /dev/null +++ b/src/main/java/com/example/demo/model/LineItem.java @@ -0,0 +1,58 @@ +package com.example.demo.model; + +import jakarta.persistence.Embedded; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "line_items") +public class LineItem { + + @EmbeddedId + private LineItemId id; + + @Embedded + private ProductId productId; + + @Embedded + private ProductOption productOption; + + private int quantity; + + protected LineItem() { + } + + public LineItem(ProductId productId, ProductOption productOption, int quantity) { + this.id = LineItemId.generate(); + this.productId = productId; + this.productOption = productOption; + this.quantity = quantity; + } + + public LineItemId getId() { + return id; + } + + public ProductId getProductId() { + return productId; + } + + public ProductOption getProductOption() { + return productOption; + } + + public int getQuantity() { + return quantity; + } + + + public void addQuantity(int quantity) { + this.quantity += quantity; + } + + public boolean isSameProduct(ProductId productId, ProductOption productOption) { + return this.productId.equals(productId) && this.productOption.equals(productOption); + } + +} diff --git a/src/main/java/com/example/demo/model/LineItemId.java b/src/main/java/com/example/demo/model/LineItemId.java new file mode 100644 index 0000000..8437fd4 --- /dev/null +++ b/src/main/java/com/example/demo/model/LineItemId.java @@ -0,0 +1,15 @@ +package com.example.demo.model; + +import jakarta.persistence.Embeddable; + +import java.util.UUID; + +@Embeddable +public record LineItemId( + String id +) { + + public static LineItemId generate() { + return new LineItemId(UUID.randomUUID().toString()); + } +} diff --git a/src/main/java/com/example/demo/model/Product.java b/src/main/java/com/example/demo/model/Product.java new file mode 100644 index 0000000..4f9bb90 --- /dev/null +++ b/src/main/java/com/example/demo/model/Product.java @@ -0,0 +1,42 @@ +package com.example.demo.model; + +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name="products") +public class Product { + + @EmbeddedId + private ProductId id; + + private String name; + private int price; + + public Product(ProductId id, String name, int price) { + this.id = id; + this.name = name; + this.price = price; + } + + public ProductId getId() { + return id; + } + + public String getName() { + return name; + } + + public int getPrice() { + return price; + } + + public void changePrice(int price) { + this.price = price; + } + + public void changeName(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/example/demo/model/ProductId.java b/src/main/java/com/example/demo/model/ProductId.java new file mode 100644 index 0000000..958c0ac --- /dev/null +++ b/src/main/java/com/example/demo/model/ProductId.java @@ -0,0 +1,9 @@ +package com.example.demo.model; + +import jakarta.persistence.Embeddable; + +@Embeddable +public record ProductId ( + String id +){ +} diff --git a/src/main/java/com/example/demo/model/ProductOption.java b/src/main/java/com/example/demo/model/ProductOption.java new file mode 100644 index 0000000..28c6919 --- /dev/null +++ b/src/main/java/com/example/demo/model/ProductOption.java @@ -0,0 +1,10 @@ +package com.example.demo.model; + +import jakarta.persistence.Embeddable; + +@Embeddable +public record ProductOption( + String color, + String size +) { +} diff --git a/src/main/java/com/example/demo/repository/CartRepository.java b/src/main/java/com/example/demo/repository/CartRepository.java new file mode 100644 index 0000000..5d43b6f --- /dev/null +++ b/src/main/java/com/example/demo/repository/CartRepository.java @@ -0,0 +1,10 @@ +package com.example.demo.repository; + +import com.example.demo.model.Cart; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CartRepository extends JpaRepository { + Optional findByUserId(String userId); +} diff --git a/src/main/java/com/example/demo/repository/ProductRepository.java b/src/main/java/com/example/demo/repository/ProductRepository.java new file mode 100644 index 0000000..82a4216 --- /dev/null +++ b/src/main/java/com/example/demo/repository/ProductRepository.java @@ -0,0 +1,8 @@ +package com.example.demo.repository; + +import com.example.demo.model.Product; +import com.example.demo.model.ProductId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/demo/util/AuthorizationUtils.java b/src/main/java/com/example/demo/util/AuthorizationUtils.java new file mode 100644 index 0000000..6d5742c --- /dev/null +++ b/src/main/java/com/example/demo/util/AuthorizationUtils.java @@ -0,0 +1,17 @@ +package com.example.demo.util; + +import com.example.demo.exception.UnauthorizedException; + +public class AuthorizationUtils { + + private AuthorizationUtils() { + } + + public static String extractUserIdFromAuthorization(String authorization) { + if (authorization == null || !authorization.startsWith("Bearer ")) { + throw new UnauthorizedException(); + } + return authorization.substring(7); + } + +} diff --git a/src/test/java/com/example/demo/application/CartServiceTest.java b/src/test/java/com/example/demo/application/CartServiceTest.java new file mode 100644 index 0000000..42ffe3d --- /dev/null +++ b/src/test/java/com/example/demo/application/CartServiceTest.java @@ -0,0 +1,137 @@ +package com.example.demo.application; + +import com.example.demo.exception.CartNotFoundException; +import com.example.demo.exception.ProductNotFoundException; +import com.example.demo.model.Cart; +import com.example.demo.model.LineItemId; +import com.example.demo.model.ProductId; +import com.example.demo.model.ProductOption; +import com.example.demo.repository.CartRepository; +import com.example.demo.repository.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CartServiceTest { + + private CartRepository cartRepository; + private ProductRepository productRepository; + private CartService cartService; + private Cart cart; + + @BeforeEach + void setUp() { + cartRepository = mock(CartRepository.class); + productRepository = mock(ProductRepository.class); + cartService = new CartService(cartRepository, productRepository); + cart = mock(Cart.class); + } + + @DisplayName("userId가 있을때 카트를 찾을수있다.") + @Test + void getCart() { + // given + String userId = "userA"; + when(cartRepository.findByUserId(userId)) + .thenReturn(Optional.of(cart)); + + // when + Cart result = cartService.getCart(userId); + + // then + assertThat(result).isSameAs(cart); + verify(cartRepository).findByUserId(userId); + } + + @DisplayName("장바구니를 못찾으면 예외가 발생한다.") + @Test + void cannotFindCart() { + String userId = "userA"; + when(cartRepository.findByUserId(userId)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> cartService.getCart(userId)) + .isInstanceOf(CartNotFoundException.class); + } + + @DisplayName("상품을 장바구니에 추가한다") + @Test + void addItemToCart_AddsProductToCart() { + // given + String userId = "userA"; + ProductId productId = new ProductId("product-1"); + ProductOption option = new ProductOption("Red", "L"); + int quantity = 2; + + when(cartRepository.findByUserId(userId)) + .thenReturn(Optional.of(cart)); + when(productRepository.existsById(productId)) + .thenReturn(true); + + // when + cartService.addItemToCart(userId, productId, option, quantity); + + // then + verify(cart).addProduct(productId, option, quantity); + } + + @DisplayName("장바구니에 넣을 상품이 존재하지 않으면 예외가 발생한다.") + @Test + void throwExceptionWhenProductNotFound() { + String userId = "userA"; + ProductId productId = new ProductId("product-1"); + ProductOption option = new ProductOption("Red", "L"); + int quantity = 2; + + when(cartRepository.findByUserId(userId)) + .thenReturn(Optional.of(cart)); + when(productRepository.existsById(productId)) + .thenReturn(false); + + assertThatThrownBy( + () -> cartService.addItemToCart(userId, productId, option, quantity)) + .isInstanceOf(ProductNotFoundException.class); + } + + @DisplayName("장바구니를 비울 수 있다.") + @Test + void clearCart() { + // given + String userId = "userA"; + + when(cartRepository.findByUserId(userId)) + .thenReturn(Optional.of(cart)); + + // when + cartService.clearCart(userId); + + // then + verify(cart).clearItems(); + } + + @DisplayName("장바구니에서 라인 아이템을 제거한다") + @Test + void removeLineItem() { + // given + String userId = "userA"; + LineItemId lineItemId = new LineItemId("lineItem-1"); + + when(cartRepository.findByUserId(userId)) + .thenReturn(Optional.of(cart)); + + // when + cartService.removeLineItem(userId, lineItemId); + + // then + verify(cart).removeLineItem(lineItemId); + } + +} diff --git a/src/test/java/com/example/demo/controllers/CartControllerTest.java b/src/test/java/com/example/demo/controllers/CartControllerTest.java index 619307d..1992009 100644 --- a/src/test/java/com/example/demo/controllers/CartControllerTest.java +++ b/src/test/java/com/example/demo/controllers/CartControllerTest.java @@ -1,12 +1,21 @@ package com.example.demo.controllers; +import com.example.demo.application.CartService; +import com.example.demo.exception.CartNotFoundException; +import com.example.demo.model.Cart; 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.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(CartController.class) @@ -14,10 +23,75 @@ class CartControllerTest { @Autowired private MockMvc mockMvc; + @MockBean + private CartService cartService; + @Test @DisplayName("GET /cart") - void detail() throws Exception { - mockMvc.perform(get("/cart")) + void getCart() throws Exception { + String userId = "userA"; + Cart cart = new Cart(); + + when(cartService.getCart(userId)).thenReturn(cart); + + mockMvc.perform(get("/cart") + .header("Authorization", "Bearer " + userId)) .andExpect(status().isOk()); } + + @Test + @DisplayName("GET /cart - 인증 헤더가 없으면 401 응답") + void getCart_unauthorized() throws Exception { + String userId = "userA"; + Cart cart = new Cart(); + + when(cartService.getCart(userId)).thenReturn(cart); + + String json = """ + { + "code": "UNAUTHORIZED", + "message": "인증이 필요합니다." + } + """; + + mockMvc.perform(get("/cart")) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(json)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET /cart - 장바구니가 없으면 404를 응답한다") + void getCart_notFound() throws Exception { + String userId = "userA"; + + when(cartService.getCart(userId)) + .thenThrow(new CartNotFoundException()); + + String json = """ + { + "code": "CART_NOT_FOUND", + "message": "장바구니를 찾을 수 없습니다." + } + """; + + + mockMvc.perform(get("/cart") + .header("Authorization", "Bearer " + userId)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().json(json)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("DELETE /cart") + void deleteCart() throws Exception { + String userId = "userA"; + + mockMvc.perform(delete("/cart") + .header("Authorization", "Bearer " + userId)) + .andExpect(status().isNoContent()); + + verify(cartService).clearCart(userId); + } } diff --git a/src/test/java/com/example/demo/controllers/LineItemControllerTest.java b/src/test/java/com/example/demo/controllers/LineItemControllerTest.java index 41eabbc..df075be 100644 --- a/src/test/java/com/example/demo/controllers/LineItemControllerTest.java +++ b/src/test/java/com/example/demo/controllers/LineItemControllerTest.java @@ -1,33 +1,75 @@ package com.example.demo.controllers; +import com.example.demo.application.CartService; +import com.example.demo.exception.GlobalExceptionHandler; +import com.example.demo.model.LineItemId; +import com.example.demo.model.ProductId; +import com.example.demo.model.ProductOption; 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.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(LineItemController.class) +@Import(GlobalExceptionHandler.class) class LineItemControllerTest { @Autowired private MockMvc mockMvc; + @MockBean + private CartService cartService; + @Test @DisplayName("POST /cart/line-items") void addProduct() throws Exception { + String userId = "userA"; + String json = """ { "productId": "product-1", + "color": "blue", + "size": "M", "quantity": 2 } """; mockMvc.perform(post("/cart/line-items") + .header("Authorization", "Bearer " + userId) .contentType(MediaType.APPLICATION_JSON) .content(json)) .andExpect(status().isCreated()); + + verify(cartService).addItemToCart( + eq(userId), + eq(new ProductId("product-1")), + eq(new ProductOption("blue", "M")), + eq(2) + ); + } + + @Test + @DisplayName("DELETE /cart/line-items/{lineItemId}") + void removeProduct() throws Exception { + String userId = "userA"; + String lineItemId = "lineItem-1"; + + mockMvc.perform(delete("/cart/line-items/{lineItemId}", lineItemId) + .header("Authorization", "Bearer " + userId)) + .andExpect(status().isNoContent()); + + verify(cartService).removeLineItem( + eq(userId), + eq(new LineItemId(lineItemId)) + ); } } diff --git a/src/test/java/com/example/demo/model/CartTest.java b/src/test/java/com/example/demo/model/CartTest.java new file mode 100644 index 0000000..2b5fbde --- /dev/null +++ b/src/test/java/com/example/demo/model/CartTest.java @@ -0,0 +1,136 @@ +package com.example.demo.model; + +import com.example.demo.exception.CartQuantityLimitException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CartTest { + + private Product product1; + private Product product2; + private ProductOption productOption1; + private ProductOption productOption2; + + @BeforeEach + void setUp() { + product1 = new Product(new ProductId("product-1"), "product #1", 5000); + product2 = new Product(new ProductId("product-2"), "product #2", 3000); + + productOption1 = new ProductOption("red", "M"); + productOption2 = new ProductOption("black", "L"); + } + + @Test + @DisplayName("빈 장바구니의 수량은 0이다.") + void cartTotalQuantityIsZero() { + Cart cart = new Cart(); + + assertThat(cart.getTotalQuantity()).isZero(); + } + + @Test + @DisplayName("빈 장바구니에 물건 추가하면 장바구니의 전체수량은 추가한 수량과 같다.") + void addProductToEmptyCart() { + Cart cart = new Cart(); + + int quantity = 1; + cart.addProduct(product1.getId(), productOption1, quantity); + + assertThat(cart.getTotalQuantity()).isEqualTo(quantity); + } + + @Test + @DisplayName("장바구니에 이미 있는 물건 추가하면 전체 수량은 이미 있던 수량과 새로 추가하는 수량의 합이다.") + void addExistingProduct() { + + int oldQuantity = 1; + int newQuantity = 1; + + Cart cart = new Cart(); + + cart.addProduct(product1.getId(), productOption1, oldQuantity); + cart.addProduct(product1.getId(), productOption1, newQuantity); + + assertThat(cart.getLineItems()).hasSize(1); + assertThat(cart.getTotalQuantity()).isEqualTo(oldQuantity + newQuantity); + } + + @Test + @DisplayName("같은 상품의 옵션이 다른 경우를 추가할때, 장바구니의 전체수량은 이미 있던 수량과 새로 추가하는 수량의 합이다. ") + void addSameProductAndDifferentOption() { + + int oldQuantity = 1; + int newQuantity = 1; + Cart cart = new Cart(); + + cart.addProduct(product1.getId(), productOption1, oldQuantity); + cart.addProduct(product1.getId(), productOption2, newQuantity); + + assertThat(cart.getLineItems()).hasSize(2); + assertThat(cart.getTotalQuantity()).isEqualTo(oldQuantity + newQuantity); + } + + @Test + @DisplayName("상품의 옵션이 모두 다른 경우를 추가할때, 장바구니의 전체수량은 이미 있던 수량과 새로 추가하는 수량의 합이다. ") + void addDifferentProductAndDifferentOption() { + + int oldQuantity = 1; + int newQuantity = 1; + Cart cart = new Cart(); + + cart.addProduct(product1.getId(), productOption1, oldQuantity); + cart.addProduct(product2.getId(), productOption2, newQuantity); + + assertThat(cart.getLineItems()).hasSize(2); + assertThat(cart.getTotalQuantity()).isEqualTo(oldQuantity + newQuantity); + } + + + @Test + @DisplayName("전체 장바구니의 수량이 20개가 넘어가면 예외가 발생한다.") + void totalQuantityCanNotOverLimit() { + Cart cart = new Cart(); + + int newQuantity = 5; + + cart.addProduct(product1.getId(), productOption1, 20); + + assertThatThrownBy( + () -> cart.addProduct(product1.getId(), productOption1, newQuantity) + ).isInstanceOf(CartQuantityLimitException.class); + } + + @Test + @DisplayName("장바구니를 비우면 전체수량이 0이 된다.") + void clearCart() { + Cart cart = new Cart(); + cart.addProduct(product1.getId(), productOption1, 1); + cart.addProduct(product2.getId(), productOption2, 2); + + cart.clearItems(); + + assertThat(cart.getLineItems()).isEmpty(); + assertThat(cart.getTotalQuantity()).isZero(); + } + + @Test + @DisplayName("장바구니에서 LineItem을 삭제하면 해당 상품을 제거할수있다.") + void removeLineItem() { + Cart cart = new Cart(); + cart.addProduct(product1.getId(), productOption1, 1); + cart.addProduct(product2.getId(), productOption2, 2); + + LineItem lineItem = cart.getLineItems().get(0); + cart.removeLineItem(lineItem.getId()); + + assertThat(cart.getLineItems()).hasSize(1); + assertThat(cart.getTotalQuantity()).isEqualTo(2); + } + +} diff --git a/src/test/java/com/example/demo/model/LineItemIdTest.java b/src/test/java/com/example/demo/model/LineItemIdTest.java new file mode 100644 index 0000000..5574c98 --- /dev/null +++ b/src/test/java/com/example/demo/model/LineItemIdTest.java @@ -0,0 +1,21 @@ +package com.example.demo.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class LineItemIdTest { + + @Test + @DisplayName("generate()를 사용하면 서로다른 아이디가 생성된다.") + void lineItemIdIsUnique() { + LineItemId id1 = LineItemId.generate(); + LineItemId id2 = LineItemId.generate(); + + assertThat(id1).isNotEqualTo(id2); + } + + +} diff --git a/src/test/java/com/example/demo/model/LineItemTest.java b/src/test/java/com/example/demo/model/LineItemTest.java new file mode 100644 index 0000000..d90ddf9 --- /dev/null +++ b/src/test/java/com/example/demo/model/LineItemTest.java @@ -0,0 +1,63 @@ +package com.example.demo.model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + + +class LineItemTest { + + private ProductId productId; + private ProductOption productOption; + + @BeforeEach + void setUp() { + productId = new ProductId("product-1"); + productOption = new ProductOption("red", "M"); + } + + @Test + @DisplayName("수량을 증가시키면 현재 수량에 더해진다.") + void addQuantity() { + + int oldQuantity = 2; + LineItem lineItem = new LineItem(productId, productOption, oldQuantity); + int newQuantity = 3; + + lineItem.addQuantity(newQuantity); + + assertThat(lineItem.getQuantity()).isEqualTo(oldQuantity + newQuantity); + } + + @Test + @DisplayName("상품ID와 옵션이 같으면 같은 라인아이템이다.") + void whenSameProductIdAndProductOption() { + LineItem lineItem = new LineItem(productId, productOption, 1); + + boolean result = lineItem.isSameProduct(new ProductId("product-1"), new ProductOption("red", "M")); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("상품ID는 같지만 옵션이 다르면 다른 라인아이템이다.") + void whenSameProductIdAndDiffrentProductOption() { + LineItem lineItem = new LineItem(productId, productOption, 1); + + boolean result = lineItem.isSameProduct(new ProductId("product-1"), new ProductOption("black", "L")); + + assertThat(result).isFalse(); + } + + @Test + @DisplayName("상품ID가 다르면 다른 라인아이템이다.") + void whenDifferentProductId() { + LineItem lineItem = new LineItem(productId, productOption, 1); + + boolean result = lineItem.isSameProduct(new ProductId("product-2"), new ProductOption("red", "M")); + + assertThat(result).isFalse(); + } +} diff --git a/src/test/java/com/example/demo/model/ProductIdTest.java b/src/test/java/com/example/demo/model/ProductIdTest.java new file mode 100644 index 0000000..e8361fa --- /dev/null +++ b/src/test/java/com/example/demo/model/ProductIdTest.java @@ -0,0 +1,20 @@ +package com.example.demo.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class ProductIdTest { + + @Test + @DisplayName("같은 값이면 같은 객체로 취급된다.") + void sameProductId() { + ProductId id1 = new ProductId("productId-1"); + ProductId id2 = new ProductId("productId-1"); + + assertThat(id1).isEqualTo(id2); + } + +} diff --git a/src/test/java/com/example/demo/model/ProductOptionTest.java b/src/test/java/com/example/demo/model/ProductOptionTest.java new file mode 100644 index 0000000..9f148df --- /dev/null +++ b/src/test/java/com/example/demo/model/ProductOptionTest.java @@ -0,0 +1,28 @@ +package com.example.demo.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProductOptionTest { + + @Test + @DisplayName("값이 같으면 같은 객체로 취급된다.") + void sameValue() { + ProductOption option1 = new ProductOption("red", "M"); + ProductOption option2 = new ProductOption("red", "M"); + + assertThat(option1).isEqualTo(option2); + } + + @Test + @DisplayName("값이 다르면 다른 객체로 취급된다.") + void differentValue() { + ProductOption option1 = new ProductOption("red", "M"); + ProductOption option2 = new ProductOption("black", "M"); + + assertThat(option1).isNotEqualTo(option2); + } + +} diff --git a/src/test/java/com/example/demo/model/ProductTest.java b/src/test/java/com/example/demo/model/ProductTest.java new file mode 100644 index 0000000..6838595 --- /dev/null +++ b/src/test/java/com/example/demo/model/ProductTest.java @@ -0,0 +1,34 @@ +package com.example.demo.model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProductTest { + + private Product product; + + @BeforeEach + void setUp() { + product = new Product(new ProductId("product-1"), "Product #1", 5000); + } + + @Test + void changeProductName(){ + + String newName = "NewProduct"; + product.changeName(newName); + + assertThat(product.getName()).isEqualTo(newName); + } + + @Test + void changeProductPrice(){ + int newPrice = 10000; + product.changePrice(newPrice); + + assertThat(product.getPrice()).isEqualTo(newPrice); + } + +}