diff --git a/pom.xml b/pom.xml index 337a47c..660f2f7 100644 --- a/pom.xml +++ b/pom.xml @@ -55,6 +55,11 @@ 2.8.5 + + org.springframework.boot + spring-boot-starter-amqp + + net.logstash.logback @@ -87,7 +92,7 @@ org.mockito mockito-core - 5.5.0 + 5.12.0 test diff --git a/src/main/java/cart/config/RabbitMQConfig.java b/src/main/java/cart/config/RabbitMQConfig.java new file mode 100644 index 0000000..a47a570 --- /dev/null +++ b/src/main/java/cart/config/RabbitMQConfig.java @@ -0,0 +1,50 @@ +package cart.config; + +//import org.springframework.amqp.core.Binding; +//import org.springframework.amqp.core.BindingBuilder; +//import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMQConfig { + + @Value("${rabbitmq.exchange.name}") + private String exchangeName; + + @Value("${rabbitmq.routing.key.checkout}") + private String checkoutRoutingKey; + + // Define the exchange (e.g., a Topic Exchange) + @Bean + TopicExchange cartEventsExchange() { + return new TopicExchange(exchangeName); + } + + // Note: The Cart Service *produces* messages. The *consumer* (Order Service) + // would typically define the Queue and the Binding. However, defining the + // exchange here is good practice for the producer. + // If the Cart service also needed to *consume* events (e.g., order confirmations), + // you would define Queues and Bindings here as well. + + /* Example Consumer setup (would be in Order Service): + @Value("${rabbitmq.queue.name.order}") // e.g., q.order.checkout + private String orderQueueName; + + @Bean + Queue orderQueue() { + return new Queue(orderQueueName, true); // durable=true + } + + @Bean + Binding orderBinding(Queue orderQueue, TopicExchange cartEventsExchange) { + return BindingBuilder.bind(orderQueue).to(cartEventsExchange).with(checkoutRoutingKey); + } + */ + + // You might also need a MessageConverter bean (e.g., Jackson2JsonMessageConverter) + // if you haven't configured one globally, to ensure your CheckoutEvent object + // is serialized correctly (usually auto-configured by Spring Boot). +} diff --git a/src/main/java/cart/controller/CartController.java b/src/main/java/cart/controller/CartController.java index 6260409..16a3a0c 100644 --- a/src/main/java/cart/controller/CartController.java +++ b/src/main/java/cart/controller/CartController.java @@ -238,4 +238,45 @@ public ResponseEntity checkoutCart( + "communicating with Order Service"); } } + + @Operation(summary = "Apply a promo code to the cart") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Promo code applied successfully"), + @ApiResponse(responseCode = "400", + description = "Invalid or expired promo code"), + @ApiResponse(responseCode = "404", + description = "Active cart not found") + }) + @PostMapping("/{customerId}/promo/{promoCode}") + public ResponseEntity applyPromoCode( + @PathVariable("customerId") final String customerId, + @PathVariable("promoCode") final String promoCode) { + log.debug("Entering applyPromoCode endpoint with " + + "customerId: {}, promoCode: {}", + customerId, promoCode); + Cart updatedCart = cartService.applyPromoCode( + customerId, promoCode); + log.debug("Promo code applied, updated cart: " + + "{}", updatedCart); + return ResponseEntity.ok(updatedCart); + } + + @Operation(summary = "Remove the applied promo code from the cart") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Promo code removed successfully"), + @ApiResponse(responseCode = "404", + description = "Active cart not found") + }) + @DeleteMapping("/{customerId}/promo") + public ResponseEntity removePromoCode( + @PathVariable("customerId") final String customerId) { + log.debug("Entering removePromoCode " + + "endpoint with customerId: {}", customerId); + Cart updatedCart = cartService.removePromoCode(customerId); + log.debug("Promo code removed (if any)," + + " updated cart: {}", updatedCart); + return ResponseEntity.ok(updatedCart); + } } diff --git a/src/main/java/cart/controller/PromoCodeController.java b/src/main/java/cart/controller/PromoCodeController.java new file mode 100644 index 0000000..e2e280d --- /dev/null +++ b/src/main/java/cart/controller/PromoCodeController.java @@ -0,0 +1,72 @@ +package cart.controller; + +import cart.model.PromoCode; +import cart.service.PromoCodeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestBody; + + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/promocodes") +@RequiredArgsConstructor +@Tag(name = "PromoCode Admin", description = "Manage promotional codes (Requires Admin Role)") +@Slf4j +public class PromoCodeController { + + private final PromoCodeService promoCodeService; + + @Operation(summary = "Create a Promo Code") + @PostMapping + public ResponseEntity createOrUpdatePromoCode( + @Valid @RequestBody final PromoCode promoCode) { + log.info("Admin request to create promo code: {}", promoCode.getCode()); + PromoCode savedPromoCode = promoCodeService.createOrUpdatePromoCode(promoCode); + return ResponseEntity.ok(savedPromoCode); + } + + @Operation(summary = "Get all Promo Codes") + @GetMapping + public ResponseEntity> getAllPromoCodes() { + log.info("Admin request to get all promo codes"); + return ResponseEntity.ok(promoCodeService.findAll()); + } + + @Operation(summary = "Get a specific Promo Code by code") + @GetMapping("/{code}") + public ResponseEntity getPromoCodeByCode( + @PathVariable final String code) { + log.info("Admin request to get promo code: {}", code); + return promoCodeService.findByCode(code) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @Operation(summary = "Delete a Promo Code by code") + @DeleteMapping("/{code}") + public ResponseEntity deletePromoCode( + @PathVariable final String code) { + log.info("Admin request to delete promo code: {}", code); + try { + promoCodeService.deletePromoCode(code); + return ResponseEntity.noContent().build(); + } catch (Exception e) { + log.error("Error deleting promo code", code, e.getMessage()); + return ResponseEntity.status( + HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } +} diff --git a/src/main/java/cart/model/Cart.java b/src/main/java/cart/model/Cart.java index 6d93d92..4c52b18 100644 --- a/src/main/java/cart/model/Cart.java +++ b/src/main/java/cart/model/Cart.java @@ -8,6 +8,7 @@ import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; @@ -28,5 +29,12 @@ public class Cart { private boolean archived = false; + private String appliedPromoCode; + + private BigDecimal subTotal = BigDecimal.ZERO; + private BigDecimal discountAmount = BigDecimal.ZERO; + private BigDecimal totalPrice = BigDecimal.ZERO; + + } diff --git a/src/main/java/cart/model/CartItem.java b/src/main/java/cart/model/CartItem.java index 1a147c3..ddac146 100644 --- a/src/main/java/cart/model/CartItem.java +++ b/src/main/java/cart/model/CartItem.java @@ -1,10 +1,14 @@ package cart.model; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.math.BigDecimal; + @Data @NoArgsConstructor @AllArgsConstructor @@ -13,6 +17,15 @@ public class CartItem { @NotBlank private String productId; + @NotNull + @PositiveOrZero private int quantity; + @NotNull + @PositiveOrZero + private BigDecimal unitPrice; + + public BigDecimal getItemTotal() { + return unitPrice.multiply(BigDecimal.valueOf(quantity)); + } } diff --git a/src/main/java/cart/model/OrderRequest.java b/src/main/java/cart/model/OrderRequest.java index 80b8bb2..2823dcf 100644 --- a/src/main/java/cart/model/OrderRequest.java +++ b/src/main/java/cart/model/OrderRequest.java @@ -1,21 +1,24 @@ package cart.model; -import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import java.math.BigDecimal; import java.util.List; @Data @NoArgsConstructor @AllArgsConstructor public class OrderRequest { - - @NotBlank + private String eventId; private String customerId; - + private String cartId; private List items; + private BigDecimal subTotal; + private BigDecimal discountAmount; + private BigDecimal totalPrice; + private String appliedPromoCode; } diff --git a/src/main/java/cart/model/PromoCode.java b/src/main/java/cart/model/PromoCode.java new file mode 100644 index 0000000..c69956d --- /dev/null +++ b/src/main/java/cart/model/PromoCode.java @@ -0,0 +1,49 @@ +package cart.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.math.BigDecimal; +import java.time.Instant; + +@Document(collection = "promo_codes") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PromoCode { + + public enum DiscountType { + PERCENTAGE, + FIXED_AMOUNT + } + + @Id + private String id; + + @NotBlank + @Indexed(unique = true) + private String code; + + private String description; + + @NotNull + private DiscountType discountType; + + @NotNull + @Positive + private BigDecimal discountValue; + + private boolean active = true; + + private Instant expiryDate; + + private BigDecimal minimumPurchaseAmount; + +} diff --git a/src/main/java/cart/repository/PromoCodeRepository.java b/src/main/java/cart/repository/PromoCodeRepository.java new file mode 100644 index 0000000..371285a --- /dev/null +++ b/src/main/java/cart/repository/PromoCodeRepository.java @@ -0,0 +1,13 @@ +package cart.repository; + + +import cart.model.PromoCode; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PromoCodeRepository extends MongoRepository { + Optional findByCode(String code); +} diff --git a/src/main/java/cart/service/CartService.java b/src/main/java/cart/service/CartService.java index cd53992..15a4413 100644 --- a/src/main/java/cart/service/CartService.java +++ b/src/main/java/cart/service/CartService.java @@ -4,111 +4,118 @@ import cart.model.Cart; import cart.model.CartItem; import cart.model.OrderRequest; +import cart.model.PromoCode; import cart.repository.CartRepository; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; +import java.math.BigDecimal; +import java.time.Instant; import java.util.ArrayList; import java.util.NoSuchElementException; +import java.util.Optional; import java.util.UUID; @Service -@RequiredArgsConstructor @Slf4j public class CartService { private final CartRepository cartRepository; + private final RabbitTemplate rabbitTemplate; + private final PromoCodeService promoCodeService; - private final RestTemplate restTemplate; - - @Value("${order.service.url}") - private String orderServiceUrl; + @Value("${rabbitmq.exchange.name}") + private String exchangeName; + @Value("${rabbitmq.routing.key.checkout}") + private String checkoutRoutingKey; + public CartService(final CartRepository cartRepository, + final RabbitTemplate rabbitTemplate, + final PromoCodeService promoCodeService) { + this.cartRepository = cartRepository; + this.rabbitTemplate = rabbitTemplate; + this.promoCodeService = promoCodeService; + } public Cart createCart(final String customerId) { - log.debug("Entering createCart" - + " with customerId:", customerId); + log.debug("Entering createCart with customerId: {}", customerId); Cart cart = cartRepository.findByCustomerId(customerId) .orElseGet(() -> { - Cart newCart = new Cart(UUID.randomUUID() - .toString(), customerId, new - ArrayList<>(), false); - log.debug("Cart created:", newCart); + Cart newCart = new Cart( + UUID.randomUUID().toString(), + customerId, + new ArrayList<>(), + false, + null, + BigDecimal.ZERO.setScale(2), + BigDecimal.ZERO.setScale(2), + BigDecimal.ZERO.setScale(2) + ); + log.debug("Cart created: {}", newCart); return cartRepository.save(newCart); }); - log.debug("Cart retrieved:", cart); + log.debug("Cart retrieved: {}", cart); return cart; } - public Cart addItemToCart(final String customerId, - final CartItem newItem) { - log.debug("Entering addItemToCart " - + "with customerId:, newItem:", - customerId, newItem); + public Cart addItemToCart(final String customerId, final CartItem newItem) { + log.debug("Entering addItemToCart with customerId: {}, newItem: {}", customerId, newItem); CartCommand command = new AddItemCommand(this, customerId, newItem); return command.execute(); } - - public Cart updateItemQuantity(final String customerId, - final String productId, final int quantity) { - log.debug("Entering updateItemQuantity with" - + " customerId:, productId:, quantity: ", - customerId, productId, quantity); + public Cart updateItemQuantity(final String customerId, final String productId, final int quantity) { + log.debug("Entering updateItemQuantity with customerId:" + + " {}, productId: {}, quantity: {}", customerId, + productId, quantity); CartCommand command = new UpdateQuantityCommand(this, customerId, productId, quantity); return command.execute(); } - - public Cart removeItemFromCart(final String customerId, - final String productId) { - log.debug("Entering removeItemFromCart" - + " with customerId:, productId:", customerId, productId); - + public Cart removeItemFromCart(final String customerId, final String productId) { + log.debug("Entering removeItemFromCart with customerId: {}, productId: {}", customerId, productId); CartCommand command = new RemoveItemCommand(this, customerId, productId); return command.execute(); } public void deleteCartByCustomerId(final String customerId) { - log.debug("Entering deleteCartByCustomerId" - + " with customerId:", customerId); + log.debug("Entering deleteCartByCustomerId with customerId: {}", customerId); cartRepository.findByCustomerId(customerId) .ifPresent(cart -> { - log.debug("Deleting cart for customerId:", customerId); + log.debug("Deleting cart for customerId: {}", customerId); cartRepository.delete(cart); }); - log.debug("Cart deletion completed for" - + " customerId:", customerId); + log.debug("Cart deletion completed for customerId: {}", customerId); } public Cart getCartByCustomerId(final String customerId) { - log.debug("Entering getCartByCustomerId" - + " with customerId:", customerId); + log.debug("Entering getCartByCustomerId with customerId: {}", customerId); Cart cart = cartRepository.findByCustomerId(customerId) .orElseThrow(() -> { - log.error("Cart not found for customerId:", customerId); - throw new GlobalHandlerException( - HttpStatus.NOT_FOUND, "Cart not found"); + log.error("Cart not found for customerId: {}", customerId); + throw new GlobalHandlerException(HttpStatus.NOT_FOUND, "Cart not found"); }); - log.debug("Cart retrieved:", cart); + log.debug("Cart retrieved: {}", cart); return cart; - } public void clearCart(final String customerId) { - log.debug("Entering clearCart with customerId:", customerId); + log.debug("Entering clearCart with customerId: {}", customerId); Cart cart = getCartByCustomerId(customerId); cart.getItems().clear(); + cart.setAppliedPromoCode(null); + cart.setSubTotal(BigDecimal.ZERO.setScale(2)); + cart.setDiscountAmount(BigDecimal.ZERO.setScale(2)); + cart.setTotalPrice(BigDecimal.ZERO.setScale(2)); cartRepository.save(cart); - log.debug("Cart cleared for customerId:", customerId); + log.debug("Cart cleared for customerId: {}", customerId); } public Cart archiveCart(final String customerId) { - log.debug("Entering archiveCart with customerId:", customerId); + log.debug("Entering archiveCart with customerId: {}", customerId); Cart cart = getActiveCart(customerId); cart.setArchived(true); Cart archivedCart = cartRepository.save(cart); @@ -117,68 +124,170 @@ public Cart archiveCart(final String customerId) { } public Cart unarchiveCart(final String customerId) { - log.debug("Entering unarchiveCart with customerId:", customerId); + log.debug("Entering unarchiveCart with customerId: {}", customerId); Cart cart = getArchivedCart(customerId); cart.setArchived(false); Cart activeCart = cartRepository.save(cart); - log.debug("Cart unarchived:", activeCart); + log.debug("Cart unarchived: {}", activeCart); return activeCart; } - public Cart checkoutCart(final String customerId) { - log.debug("Entering checkoutCart with customerId:", customerId); - Cart cart = getActiveCart(customerId); - - OrderRequest orderRequest = new OrderRequest(); - orderRequest.setCustomerId(customerId); - orderRequest.setItems(cart.getItems()); - - try { - log.debug("Sending order request to" - + " Order Service for customerId:", customerId); - restTemplate.postForObject(orderServiceUrl - + "/orders", orderRequest, Void.class); - cart.getItems().clear(); - Cart updatedCart = cartRepository.save(cart); - log.debug("Cart checked out and cleared:", updatedCart); - return updatedCart; - } catch (Exception e) { - log.error("Failed to checkout cart for customerId:", customerId, e); - throw new RuntimeException("Error" - + " communicating with Order Service", e); - } - } - - private Cart getActiveCart(final String customerId) { - log.debug("Entering getActiveCart with customerId:", customerId); - Cart cart = cartRepository.findByCustomerIdAndArchived(customerId, - false) + log.debug("Entering getActiveCart with customerId: {}", customerId); + Cart cart = cartRepository.findByCustomerIdAndArchived(customerId, false) .orElseThrow(() -> { - log.error("Active cart not found" - + " for customerId:", customerId); - return new NoSuchElementException("Cart not" - + " found for customer ID: " + customerId); + log.error("Active cart not found for customerId: {}", customerId); + return new NoSuchElementException("Cart not found for customer ID: " + customerId); }); - log.debug("Active cart retrieved:", cart); + log.debug("Active cart retrieved: {}", cart); return cart; } private Cart getArchivedCart(final String customerId) { - log.debug("Entering getArchivedCart with customerId:", customerId); + log.debug("Entering getArchivedCart with customerId: {}", customerId); Cart cart = cartRepository.findByCustomerIdAndArchived(customerId, true) .orElseThrow(() -> { - log.error("Archived cart not found" - + " for customerId:", customerId); - return new NoSuchElementException("No archived " - + "cart found for customer ID: " + customerId); + log.error("Archived cart not found for customerId: {}", customerId); + return new NoSuchElementException("No archived cart found for customer ID: " + customerId); }); - log.debug("Archived cart retrieved:", cart); + log.debug("Archived cart retrieved: {}", cart); return cart; } + public Cart applyPromoCode(final String customerId, final String promoCodeInput) { + log.debug("Entering applyPromoCode for customerId: {}, promoCode: {}", customerId, promoCodeInput); + Cart cart = getActiveCart(customerId); + String promoCodeUpper = promoCodeInput.toUpperCase(); + + PromoCode promoCode = promoCodeService.getActivePromoCode(promoCodeUpper) + .orElseThrow(() -> new GlobalHandlerException( + HttpStatus.BAD_REQUEST, "Invalid, inactive, or expired promo code: " + promoCodeInput)); + + log.info("Applying valid promo code '{}' to cartId: {}", promoCodeUpper, cart.getId()); + cart.setAppliedPromoCode(promoCodeUpper); + + return saveCart(cart); + } + + public Cart removePromoCode(final String customerId) { + log.debug("Entering removePromoCode for customerId: {}", customerId); + Cart cart = getActiveCart(customerId); + + if (cart.getAppliedPromoCode() != null) { + log.info("Removing applied promo code '{}' from cartId: {}", cart.getAppliedPromoCode(), cart.getId()); + cart.setAppliedPromoCode(null); + return saveCart(cart); + } else { + log.debug("No promo code to remove from cartId: {}", cart.getId()); + return cart; + } + } + public Cart saveCart(final Cart cart) { + log.debug("Preparing to save cartId: {}", cart.getId()); + recalculateCartTotals(cart); + log.debug("Saving cart with updated totals: {}", cart); return cartRepository.save(cart); } + private void recalculateCartTotals(final Cart cart) { + log.debug("Recalculating totals for cartId: {}", cart.getId()); + + BigDecimal subTotal = calculateSubTotal(cart); + String formattedSubTotal = String.format("%.2f", subTotal); + cart.setSubTotal(new BigDecimal(formattedSubTotal)); + + BigDecimal discountAmount = BigDecimal.ZERO; + if (cart.getAppliedPromoCode() != null) { + Optional promoOpt = promoCodeService.getActivePromoCode(cart.getAppliedPromoCode()); + + if (promoOpt.isPresent()) { + PromoCode promo = promoOpt.get(); + boolean validForCart = true; + + if (promo.getExpiryDate() != null && promo.getExpiryDate().isBefore(Instant.now())) { + log.warn("Applied promo code {} is expired. Removing.", cart.getAppliedPromoCode()); + cart.setAppliedPromoCode(null); + validForCart = false; + } + + if (validForCart) { + if (promo.getDiscountType() == PromoCode.DiscountType.PERCENTAGE) { + BigDecimal percentageValue = promo.getDiscountValue().divide(new BigDecimal("100")); + String formattedPercentage = String.format("%.2f", percentageValue); + BigDecimal percentage = new BigDecimal(formattedPercentage); + discountAmount = subTotal.multiply(percentage); + } else if (promo.getDiscountType() == PromoCode.DiscountType.FIXED_AMOUNT) { + discountAmount = promo.getDiscountValue(); + } + } + } else { + log.warn("Applied promo code {} is no longer valid. Removing.", cart.getAppliedPromoCode()); + cart.setAppliedPromoCode(null); + } + } + + discountAmount = discountAmount.max(BigDecimal.ZERO); + discountAmount = discountAmount.min(subTotal); + String formattedDiscountAmount = String.format("%.2f", discountAmount); + cart.setDiscountAmount(new BigDecimal(formattedDiscountAmount)); + + BigDecimal totalPrice = subTotal.subtract(discountAmount); + String formattedTotalPrice = String.format("%.2f", totalPrice); + cart.setTotalPrice(new BigDecimal(formattedTotalPrice)); + + log.debug("Recalculated totals for cartId: {}: SubTotal={}, Discount={}, Total={}", + cart.getId(), cart.getSubTotal(), cart.getDiscountAmount(), cart.getTotalPrice()); + } + + private BigDecimal calculateSubTotal(final Cart cart) { + if (cart.getItems() == null) { + return BigDecimal.ZERO; + } + return cart.getItems().stream() + .filter(item -> item.getUnitPrice() != null && item.getQuantity() > 0) + .map(CartItem::getItemTotal) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + public Cart checkoutCart(final String customerId) { + log.debug("Entering checkoutCart [RabbitMQ] for customerId: {}", customerId); + Cart cart = getActiveCart(customerId); + + recalculateCartTotals(cart); + + if (cart.getItems().isEmpty()) { + log.warn("Attempted checkout for customerId: {} with an empty cart.", customerId); + throw new GlobalHandlerException(HttpStatus.BAD_REQUEST, "Cannot checkout an empty cart."); + } + + OrderRequest checkoutEvent = new OrderRequest( + UUID.randomUUID().toString(), + customerId, + cart.getId(), + new ArrayList<>(cart.getItems()), + cart.getSubTotal(), + cart.getDiscountAmount(), + cart.getTotalPrice(), + cart.getAppliedPromoCode() + ); + + try { + log.debug("Publishing checkout event for cartId: {} with totals: Sub={}, Discount={}, Total={}", + cart.getId(), cart.getSubTotal(), cart.getDiscountAmount(), cart.getTotalPrice()); + rabbitTemplate.convertAndSend(exchangeName, checkoutRoutingKey, checkoutEvent); + + log.info("Checkout event published successfully for cartId: {}. Clearing cart.", cart.getId()); + cart.getItems().clear(); + cart.setAppliedPromoCode(null); + Cart updatedCart = saveCart(cart); + + log.debug("Cart cleared and saved after checkout: {}", updatedCart); + return updatedCart; + + } catch (Exception e) { + log.error("Failed to publish checkout event for cartId: {}. Error: {}", cart.getId(), e.getMessage(), e); + throw new RuntimeException("Checkout process failed: Could not publish event.", e); + } + } } diff --git a/src/main/java/cart/service/PromoCodeService.java b/src/main/java/cart/service/PromoCodeService.java new file mode 100644 index 0000000..c3767af --- /dev/null +++ b/src/main/java/cart/service/PromoCodeService.java @@ -0,0 +1,52 @@ +package cart.service; + +import cart.exception.GlobalHandlerException; +import cart.model.PromoCode; +import cart.repository.PromoCodeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PromoCodeService { + + private final PromoCodeRepository promoCodeRepository; + + public PromoCode createOrUpdatePromoCode(final PromoCode promoCode) { + log.info("Creating/Updating promo code: {}", promoCode.getCode()); + promoCode.setCode(promoCode.getCode().toUpperCase()); + Optional existing = promoCodeRepository.findByCode(promoCode.getCode()); + existing.ifPresent(value -> promoCode.setId(value.getId())); + + return promoCodeRepository.save(promoCode); + } + + public Optional findByCode(final String code) { + return promoCodeRepository.findByCode(code.toUpperCase()); + } + + public List findAll() { + return promoCodeRepository.findAll(); + } + + public void deletePromoCode(final String code) { + PromoCode promo = promoCodeRepository.findByCode( + code.toUpperCase()) + .orElseThrow(() -> new + GlobalHandlerException(HttpStatus.NOT_FOUND, + "Promo code not found: " + code)); + promoCodeRepository.delete(promo); + log.info("Deleted promo code: {}", code); + } + + public Optional getActivePromoCode(final String code) { + return promoCodeRepository.findByCode(code.toUpperCase()) + .filter(PromoCode::isActive); + } +} diff --git a/src/main/java/cart/service/RemoveItemCommand.java b/src/main/java/cart/service/RemoveItemCommand.java index 1486f4a..1a7bc00 100644 --- a/src/main/java/cart/service/RemoveItemCommand.java +++ b/src/main/java/cart/service/RemoveItemCommand.java @@ -18,7 +18,8 @@ public class RemoveItemCommand implements CartCommand { @Override public Cart execute() { - log.debug("Executing RemoveItemCommand for customerId: {}, productId: {}", customerId, productId); + log.debug("Executing RemoveItemCommand for customerId: " + + "{}, productId: {}", customerId, productId); Cart cart = cartService.getCartByCustomerId(customerId); Optional itemToRemove = cart.getItems().stream() @@ -26,7 +27,8 @@ public Cart execute() { .findFirst(); if (itemToRemove.isPresent()) { - removedItem = new CartItem(itemToRemove.get().getProductId(), itemToRemove.get().getQuantity()); + removedItem = new CartItem(itemToRemove.get().getProductId(), + itemToRemove.get().getQuantity(), itemToRemove.get().getUnitPrice()); cart.getItems().removeIf(i -> i.getProductId().equals(productId)); log.debug("Item removed for productId: {}", productId); } else { diff --git a/src/main/java/cart/service/UpdateQuantityCommand.java b/src/main/java/cart/service/UpdateQuantityCommand.java index 0b3f4b7..5ea737d 100644 --- a/src/main/java/cart/service/UpdateQuantityCommand.java +++ b/src/main/java/cart/service/UpdateQuantityCommand.java @@ -73,7 +73,7 @@ public Cart undo() { if (previousQuantity <= 0) { log.debug("Restoring removed item during " + "undo for productId: {}", productId); - cart.getItems().add(new CartItem(productId, previousQuantity)); + cart.getItems().add(new CartItem(productId, previousQuantity, existingItemOpt.get().getUnitPrice())); } else if (existingItemOpt.isPresent()) { log.debug("Restoring previous quantity " + "during undo for productId: {}", productId); @@ -81,7 +81,7 @@ public Cart undo() { } else { log.debug("Adding item back during" + " undo for productId: {}", productId); - cart.getItems().add(new CartItem(productId, previousQuantity)); + cart.getItems().add(new CartItem(productId, previousQuantity, existingItemOpt.get().getUnitPrice())); } Cart updatedCart = cartService.saveCart(cart); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f14db7a..ac948a4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,4 +2,14 @@ order.service.url=http://order-service:8082 spring.data.mongodb.uri=mongodb://localhost:27018/cartDB logging.file.name=./logs/app.log logging.level.root=info -logging.level.com.podzilla.cart=debug \ No newline at end of file +logging.level.com.podzilla.cart=debug +# RabbitMQ Configuration +spring.rabbitmq.host=localhost +spring.rabbitmq.port=5672 +spring.rabbitmq.username=guest # Use appropriate credentials +spring.rabbitmq.password=guest # Use appropriate credentials +# spring.rabbitmq.virtual-host=/ # Optional + +# Custom properties for exchange/routing keys +rabbitmq.exchange.name=cart.events +rabbitmq.routing.key.checkout=order.checkout.initiate diff --git a/src/test/java/service/CartServiceTest.java b/src/test/java/service/CartServiceTest.java index d7670d3..da519e5 100644 --- a/src/test/java/service/CartServiceTest.java +++ b/src/test/java/service/CartServiceTest.java @@ -3,26 +3,30 @@ import cart.model.Cart; import cart.model.CartItem; import cart.model.OrderRequest; +import cart.model.PromoCode; import cart.repository.CartRepository; import cart.service.CartService; +import cart.service.PromoCodeService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.http.HttpStatus; import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.client.RestTemplate; +import java.math.BigDecimal; +import java.time.Instant; import java.util.ArrayList; import java.util.NoSuchElementException; import java.util.Optional; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -32,32 +36,66 @@ class CartServiceTest { private CartRepository cartRepository; @Mock - private RestTemplate restTemplate; + private RabbitTemplate rabbitTemplate; + + @Mock + private PromoCodeService promoCodeService; @InjectMocks private CartService cartService; private Cart cart; - private CartItem cartItem; + private CartItem item1Input; + private CartItem item2Input; + private final String customerId = "cust123"; - private final String productId = "prod456"; + private final String productId1 = "prod1"; + private final String productId2 = "prod2"; + private final BigDecimal price1 = new BigDecimal("10.50"); + private final BigDecimal price2 = new BigDecimal("5.00"); private final String cartId = UUID.randomUUID().toString(); - private final String orderServiceUrl = "http://localhost:8080"; + + private final String exchangeName = "test.cart.events"; + private final String checkoutRoutingKey = "test.order.checkout.initiate"; + + private Cart createNewTestCart(String cId, String crtId) { + return new Cart(crtId, cId, new ArrayList<>(), false, null, + BigDecimal.ZERO.setScale(2), BigDecimal.ZERO.setScale(2), BigDecimal.ZERO.setScale(2)); + } + + private PromoCode createTestPromoCode(String code, PromoCode.DiscountType type, BigDecimal value, BigDecimal minPurchase, Instant expiry, boolean active) { + PromoCode promo = new PromoCode(); + promo.setCode(code.toUpperCase()); + promo.setDiscountType(type); + promo.setDiscountValue(value); + promo.setMinimumPurchaseAmount(minPurchase); + promo.setExpiryDate(expiry); + promo.setActive(active); + return promo; + } @BeforeEach void setUp() { - // Initialize test data - cart = new Cart(cartId, customerId, new ArrayList<>(), false); - cartItem = new CartItem(productId, 1); + cart = createNewTestCart(customerId, cartId); - // Set orderServiceUrl - ReflectionTestUtils.setField(cartService, "orderServiceUrl", orderServiceUrl); + item1Input = new CartItem(productId1, 1, price1); + item2Input = new CartItem(productId2, 2, price2); + + ReflectionTestUtils.setField(cartService, "exchangeName", exchangeName); + ReflectionTestUtils.setField(cartService, "checkoutRoutingKey", checkoutRoutingKey); + + lenient().when(cartRepository.save(any(Cart.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + lenient().when(cartRepository.findByCustomerId(anyString())).thenReturn(Optional.empty()); + lenient().when(cartRepository.findByCustomerId(eq(customerId))).thenReturn(Optional.of(cart)); + + lenient().when(cartRepository.findByCustomerIdAndArchived(anyString(), anyBoolean())).thenReturn(Optional.empty()); + lenient().when(cartRepository.findByCustomerIdAndArchived(eq(customerId), eq(false))).thenReturn(Optional.of(cart)); + lenient().when(cartRepository.findByCustomerIdAndArchived(eq(customerId), eq(true))).thenReturn(Optional.of(createNewTestCart(customerId, cartId + "_archived"))); } @Test - void createCart_existingCart_returnsCart() { - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.of(cart)); - + void createCart_existingCart_returnsCartAndDoesNotSave() { Cart result = cartService.createCart(customerId); assertEquals(cart, result); @@ -66,269 +104,340 @@ void createCart_existingCart_returnsCart() { } @Test - void createCart_noExistingCart_createsAndSavesCart() { - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.empty()); - when(cartRepository.save(any(Cart.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - Cart result = cartService.createCart(customerId); - - assertEquals(customerId, result.getCustomerId()); + void createCart_noExistingCart_createsAndSavesNewCartWithZeroTotals() { + String newCustId = "newCust456"; + when(cartRepository.findByCustomerId(eq(newCustId))).thenReturn(Optional.empty()); + when(cartRepository.save(any(Cart.class))).thenAnswer(invocation -> { + Cart newCart = invocation.getArgument(0); + if (newCart.getId() == null) newCart.setId(UUID.randomUUID().toString()); + return newCart; + }); + + Cart result = cartService.createCart(newCustId); + + assertEquals(newCustId, result.getCustomerId()); + assertNotNull(result.getId()); assertFalse(result.isArchived()); assertTrue(result.getItems().isEmpty()); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository).save(any(Cart.class)); + ArgumentCaptor cartCaptor = ArgumentCaptor.forClass(Cart.class); + verify(cartRepository).save(cartCaptor.capture()); + Cart savedCart = cartCaptor.getValue(); + assertEquals(BigDecimal.ZERO.setScale(2), savedCart.getSubTotal()); + assertEquals(BigDecimal.ZERO.setScale(2), savedCart.getDiscountAmount()); + assertEquals(BigDecimal.ZERO.setScale(2), savedCart.getTotalPrice()); + assertNull(result.getAppliedPromoCode()); + + verify(cartRepository).findByCustomerId(eq(newCustId)); } @Test - void addItemToCart_newItem_addsItem() { - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.of(cart)); - when(cartRepository.save(any(Cart.class))).thenReturn(cart); - - Cart result = cartService.addItemToCart(customerId, cartItem); + void addItemToCart_newItem_addsItemAndRecalculatesTotals() { + Cart result = cartService.addItemToCart(customerId, item1Input); assertEquals(1, result.getItems().size()); - assertEquals(cartItem.getProductId(), result.getItems().get(0).getProductId()); - assertEquals(cartItem.getQuantity(), result.getItems().get(0).getQuantity()); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository).save(cart); + CartItem added = result.getItems().get(0); + assertEquals(productId1, added.getProductId()); + assertEquals(1, added.getQuantity()); + assertEquals(price1, added.getUnitPrice()); + + assertEquals(new BigDecimal("10.50").setScale(2), result.getSubTotal()); + assertEquals(BigDecimal.ZERO.setScale(2), result.getDiscountAmount()); + assertEquals(new BigDecimal("10.50").setScale(2), result.getTotalPrice()); + verify(cartRepository, times(1)).save(any(Cart.class)); } @Test - void addItemToCart_existingItem_updatesQuantity() { - cart.getItems().add(new CartItem(productId, 1)); - CartItem newItem = new CartItem(productId, 2); - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.of(cart)); - when(cartRepository.save(any(Cart.class))).thenReturn(cart); + void addItemToCart_existingItem_updatesQuantityAndRecalculatesTotals() { + cart.getItems().add(new CartItem(productId1, 1, price1)); - Cart result = cartService.addItemToCart(customerId, newItem); + CartItem additionalItem1 = new CartItem(productId1, 2, price1); + Cart result = cartService.addItemToCart(customerId, additionalItem1); assertEquals(1, result.getItems().size()); - assertEquals(3, result.getItems().get(0).getQuantity()); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository).save(cart); + CartItem updatedItem = result.getItems().get(0); + assertEquals(productId1, updatedItem.getProductId()); + assertEquals(3, updatedItem.getQuantity()); + assertEquals(new BigDecimal("31.50").setScale(2), result.getSubTotal()); + assertEquals(BigDecimal.ZERO.setScale(2), result.getDiscountAmount()); + assertEquals(new BigDecimal("31.50").setScale(2), result.getTotalPrice()); + verify(cartRepository, times(1)).save(any(Cart.class)); } @Test - void addItemToCart_cartNotFound_throwsGlobalHandlerException() { - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.empty()); + void updateItemQuantity_existingItem_updatesAndRecalculates() { + cart.getItems().add(new CartItem(productId1, 2, price1)); - GlobalHandlerException exception = assertThrows(GlobalHandlerException.class, - () -> cartService.addItemToCart(customerId, cartItem)); - assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); - assertEquals("Cart not found", exception.getMessage()); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository, never()).save(any()); + Cart result = cartService.updateItemQuantity(customerId, productId1, 5); + + assertEquals(1, result.getItems().size()); + assertEquals(5, result.getItems().get(0).getQuantity()); + assertEquals(new BigDecimal("52.50").setScale(2), result.getSubTotal()); + assertEquals(BigDecimal.ZERO.setScale(2), result.getDiscountAmount()); + assertEquals(new BigDecimal("52.50").setScale(2), result.getTotalPrice()); + verify(cartRepository, times(1)).save(any(Cart.class)); } @Test - void updateItemQuantity_existingItem_updatesQuantity() { - cart.getItems().add(new CartItem(productId, 1)); - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.of(cart)); - when(cartRepository.save(any(Cart.class))).thenReturn(cart); + void updateItemQuantity_quantityToZero_removesItemAndRecalculates() { + cart.getItems().add(new CartItem(productId1, 2, price1)); + cart.getItems().add(new CartItem(productId2, 1, price2)); - Cart result = cartService.updateItemQuantity(customerId, productId, 5); + Cart result = cartService.updateItemQuantity(customerId, productId1, 0); - assertEquals(5, result.getItems().get(0).getQuantity()); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository).save(cart); + assertEquals(1, result.getItems().size()); + assertEquals(productId2, result.getItems().get(0).getProductId()); + assertEquals(new BigDecimal("5.00").setScale(2), result.getSubTotal()); + assertEquals(BigDecimal.ZERO.setScale(2), result.getDiscountAmount()); + assertEquals(new BigDecimal("5.00").setScale(2), result.getTotalPrice()); + verify(cartRepository, times(1)).save(any(Cart.class)); } @Test - void updateItemQuantity_quantityZero_removesItem() { - cart.getItems().add(new CartItem(productId, 1)); - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.of(cart)); - when(cartRepository.save(any(Cart.class))).thenReturn(cart); + void removeItemFromCart_itemExists_removesAndRecalculates() { + cart.getItems().add(new CartItem(productId1, 2, price1)); + cart.getItems().add(new CartItem(productId2, 1, price2)); - Cart result = cartService.updateItemQuantity(customerId, productId, 0); + Cart result = cartService.removeItemFromCart(customerId, productId1); - assertTrue(result.getItems().isEmpty()); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository).save(cart); + assertEquals(1, result.getItems().size()); + assertEquals(productId2, result.getItems().get(0).getProductId()); + assertEquals(new BigDecimal("5.00").setScale(2), result.getSubTotal()); + assertEquals(BigDecimal.ZERO.setScale(2), result.getDiscountAmount()); + assertEquals(new BigDecimal("5.00").setScale(2), result.getTotalPrice()); + verify(cartRepository, times(1)).save(any(Cart.class)); } @Test - void updateItemQuantity_itemNotFound_throwsGlobalHandlerException() { - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.of(cart)); + void clearCart_itemsExist_clearsItemsAndResetsTotalsAndPromo() { + cart.getItems().add(new CartItem(productId1, 1, price1)); + cart.setAppliedPromoCode("TESTCODE"); + cart.setSubTotal(new BigDecimal("10.50")); + cart.setDiscountAmount(new BigDecimal("1.00")); + cart.setTotalPrice(new BigDecimal("9.50")); - GlobalHandlerException exception = assertThrows(GlobalHandlerException.class, - () -> cartService.updateItemQuantity(customerId, productId, 5)); - assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); - assertEquals("Product not found in cart", exception.getMessage()); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository, never()).save(any()); + cartService.clearCart(customerId); + + ArgumentCaptor cartCaptor = ArgumentCaptor.forClass(Cart.class); + verify(cartRepository).save(cartCaptor.capture()); + Cart savedCart = cartCaptor.getValue(); + assertTrue(savedCart.getItems().isEmpty()); + assertNull(savedCart.getAppliedPromoCode()); + assertEquals(BigDecimal.ZERO.setScale(2), savedCart.getSubTotal()); + assertEquals(BigDecimal.ZERO.setScale(2), savedCart.getDiscountAmount()); + assertEquals(BigDecimal.ZERO.setScale(2), savedCart.getTotalPrice()); } @Test - void removeItemFromCart_itemExists_removesItem() { - cart.getItems().add(new CartItem(productId, 1)); - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.of(cart)); - when(cartRepository.save(any(Cart.class))).thenReturn(cart); + void applyPromoCode_validPercentageCode_calculatesDiscount() { + cart.getItems().add(new CartItem(productId1, 2, new BigDecimal("10.00"))); - Cart result = cartService.removeItemFromCart(customerId, productId); + String promoCodeStr = "SAVE10"; + PromoCode promo = createTestPromoCode(promoCodeStr, PromoCode.DiscountType.PERCENTAGE, new BigDecimal("10"), null, null, true); + when(promoCodeService.getActivePromoCode(promoCodeStr.toUpperCase())).thenReturn(Optional.of(promo)); - assertTrue(result.getItems().isEmpty()); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository).save(cart); + Cart result = cartService.applyPromoCode(customerId, promoCodeStr); + + assertEquals(promoCodeStr.toUpperCase(), result.getAppliedPromoCode()); + assertEquals(new BigDecimal("20.00").setScale(2), result.getSubTotal()); + assertEquals(new BigDecimal("2.00").setScale(2), result.getDiscountAmount()); + assertEquals(new BigDecimal("18.00").setScale(2), result.getTotalPrice()); + verify(cartRepository, times(1)).save(any(Cart.class)); } @Test - void removeItemFromCart_cartNotFound_throwsGlobalHandlerException() { - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.empty()); + void applyPromoCode_validFixedCode_calculatesDiscount() { + cart.getItems().add(new CartItem(productId1, 3, new BigDecimal("10.00"))); - GlobalHandlerException exception = assertThrows(GlobalHandlerException.class, - () -> cartService.removeItemFromCart(customerId, productId)); - assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); - assertEquals("Cart not found", exception.getMessage()); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository, never()).save(any()); + String promoCodeStr = "5OFF"; + PromoCode promo = createTestPromoCode(promoCodeStr, PromoCode.DiscountType.FIXED_AMOUNT, new BigDecimal("5.00"), null, null, true); + when(promoCodeService.getActivePromoCode(promoCodeStr.toUpperCase())).thenReturn(Optional.of(promo)); + + Cart result = cartService.applyPromoCode(customerId, promoCodeStr); + + assertEquals(promoCodeStr.toUpperCase(), result.getAppliedPromoCode()); + assertEquals(new BigDecimal("30.00").setScale(2), result.getSubTotal()); + assertEquals(new BigDecimal("5.00").setScale(2), result.getDiscountAmount()); + assertEquals(new BigDecimal("25.00").setScale(2), result.getTotalPrice()); + verify(cartRepository, times(1)).save(any(Cart.class)); } @Test - void deleteCartByCustomerId_cartExists_deletesCart() { - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.of(cart)); + void applyPromoCode_fixedDiscountExceedsSubtotal_discountCapped() { + cart.getItems().add(new CartItem(productId1, 1, new BigDecimal("3.00"))); - cartService.deleteCartByCustomerId(customerId); + String promoCodeStr = "BIGOFF"; + PromoCode promo = createTestPromoCode(promoCodeStr, PromoCode.DiscountType.FIXED_AMOUNT, new BigDecimal("5.00"), null, null, true); + when(promoCodeService.getActivePromoCode(promoCodeStr.toUpperCase())).thenReturn(Optional.of(promo)); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository).delete(cart); + Cart result = cartService.applyPromoCode(customerId, promoCodeStr); + + assertEquals(new BigDecimal("3.00").setScale(2), result.getSubTotal()); + assertEquals(new BigDecimal("3.00").setScale(2), result.getDiscountAmount()); + assertEquals(BigDecimal.ZERO.setScale(2), result.getTotalPrice()); } @Test - void deleteCartByCustomerId_cartNotFound_doesNothing() { - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.empty()); + void applyPromoCode_invalidCode_throwsGlobalHandlerException() { + String invalidCode = "FAKECODE"; + when(promoCodeService.getActivePromoCode(invalidCode.toUpperCase())).thenReturn(Optional.empty()); - cartService.deleteCartByCustomerId(customerId); + GlobalHandlerException ex = assertThrows(GlobalHandlerException.class, + () -> cartService.applyPromoCode(customerId, invalidCode)); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository, never()).delete(any()); + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatus()); + assertTrue(ex.getMessage().contains("Invalid, inactive, or expired promo code")); + verify(cartRepository, never()).save(any()); } @Test - void getCartByCustomerId_cartExists_returnsCart() { - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.of(cart)); + void applyPromoCode_expiredCode_removesCodeAndNoDiscount() { + cart.getItems().add(new CartItem(productId1, 1, new BigDecimal("100.00"))); - Cart result = cartService.getCartByCustomerId(customerId); + String promoCodeStr = "EXPIRED"; + PromoCode promo = createTestPromoCode(promoCodeStr, PromoCode.DiscountType.PERCENTAGE, new BigDecimal("10"), + null, Instant.now().minusSeconds(3600), true); + when(promoCodeService.getActivePromoCode(promoCodeStr.toUpperCase())).thenReturn(Optional.of(promo)); - assertEquals(cart, result); - verify(cartRepository).findByCustomerId(customerId); + Cart result = cartService.applyPromoCode(customerId, promoCodeStr); + + assertNull(result.getAppliedPromoCode()); + assertEquals(new BigDecimal("100.00").setScale(2), result.getSubTotal()); + assertEquals(BigDecimal.ZERO.setScale(2), result.getDiscountAmount()); + assertEquals(new BigDecimal("100.00").setScale(2), result.getTotalPrice()); + verify(cartRepository, times(1)).save(any(Cart.class)); } @Test - void getCartByCustomerId_cartNotFound_throwsGlobalHandlerException() { - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.empty()); - - GlobalHandlerException exception = assertThrows(GlobalHandlerException.class, - () -> cartService.getCartByCustomerId(customerId)); - assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); - assertEquals("Cart not found", exception.getMessage()); - verify(cartRepository).findByCustomerId(customerId); + void removePromoCode_codeExists_removesCodeAndResetsDiscount() { + cart.getItems().add(new CartItem(productId1, 2, new BigDecimal("10.00"))); + cart.setAppliedPromoCode("SAVE10"); + cart.setSubTotal(new BigDecimal("20.00")); + cart.setDiscountAmount(new BigDecimal("2.00")); + cart.setTotalPrice(new BigDecimal("18.00")); + + Cart result = cartService.removePromoCode(customerId); + + assertNull(result.getAppliedPromoCode()); + assertEquals(new BigDecimal("20.00").setScale(2), result.getSubTotal()); + assertEquals(BigDecimal.ZERO.setScale(2), result.getDiscountAmount()); + assertEquals(new BigDecimal("20.00").setScale(2), result.getTotalPrice()); + verify(cartRepository, times(1)).save(any(Cart.class)); } @Test - void clearCart_cartExists_clearsItems() { - cart.getItems().add(new CartItem(productId, 1)); - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.of(cart)); - when(cartRepository.save(any(Cart.class))).thenReturn(cart); + void checkoutCart_validCartWithPromo_publishesEventAndClearsCart() { + cart.getItems().add(new CartItem(productId1, 1, new BigDecimal("100.00"))); + cart.setAppliedPromoCode("SAVE10"); + cart.setSubTotal(new BigDecimal("100.00")); + cart.setDiscountAmount(new BigDecimal("10.00")); + cart.setTotalPrice(new BigDecimal("90.00")); - cartService.clearCart(customerId); + PromoCode promo = createTestPromoCode("SAVE10", PromoCode.DiscountType.PERCENTAGE, new BigDecimal("10"), null, null, true); + when(promoCodeService.getActivePromoCode("SAVE10")).thenReturn(Optional.of(promo)); - assertTrue(cart.getItems().isEmpty()); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository).save(cart); - } + doNothing().when(rabbitTemplate).convertAndSend(anyString(), anyString(), any(OrderRequest.class)); - @Test - void clearCart_cartNotFound_throwsGlobalHandlerException() { - when(cartRepository.findByCustomerId(customerId)).thenReturn(Optional.empty()); + Cart result = cartService.checkoutCart(customerId); - GlobalHandlerException exception = assertThrows(GlobalHandlerException.class, - () -> cartService.clearCart(customerId)); - assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); - assertEquals("Cart not found", exception.getMessage()); - verify(cartRepository).findByCustomerId(customerId); - verify(cartRepository, never()).save(any()); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(OrderRequest.class); + verify(rabbitTemplate).convertAndSend(eq(exchangeName), eq(checkoutRoutingKey), eventCaptor.capture()); + OrderRequest publishedEvent = eventCaptor.getValue(); + + assertEquals(customerId, publishedEvent.getCustomerId()); + assertEquals(cartId, publishedEvent.getCartId()); + assertEquals(1, publishedEvent.getItems().size()); + assertEquals(new BigDecimal("100.00").setScale(2), publishedEvent.getSubTotal()); + assertEquals(new BigDecimal("10.00").setScale(2), publishedEvent.getDiscountAmount()); + assertEquals(new BigDecimal("90.00").setScale(2), publishedEvent.getTotalPrice()); + assertEquals("SAVE10", publishedEvent.getAppliedPromoCode()); + + assertTrue(result.getItems().isEmpty()); + assertNull(result.getAppliedPromoCode()); + assertEquals(BigDecimal.ZERO.setScale(2), result.getSubTotal()); + assertEquals(BigDecimal.ZERO.setScale(2), result.getDiscountAmount()); + assertEquals(BigDecimal.ZERO.setScale(2), result.getTotalPrice()); + + verify(cartRepository, times(1)).save(any(Cart.class)); } @Test - void archiveCart_activeCart_archivesCart() { + void checkoutCart_emptyCart_throwsGlobalHandlerException() { when(cartRepository.findByCustomerIdAndArchived(customerId, false)).thenReturn(Optional.of(cart)); - when(cartRepository.save(any(Cart.class))).thenReturn(cart); - Cart result = cartService.archiveCart(customerId); + GlobalHandlerException ex = assertThrows(GlobalHandlerException.class, + () -> cartService.checkoutCart(customerId)); - assertTrue(result.isArchived()); - verify(cartRepository).findByCustomerIdAndArchived(customerId, false); - verify(cartRepository).save(cart); + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatus()); + assertEquals("Cannot checkout an empty cart.", ex.getMessage()); + verify(rabbitTemplate, never()).convertAndSend(anyString(), anyString(), any(OrderRequest.class)); } @Test - void archiveCart_noActiveCart_throwsNoSuchElementException() { - when(cartRepository.findByCustomerIdAndArchived(customerId, false)).thenReturn(Optional.empty()); + void checkoutCart_rabbitMqFails_throwsRuntimeExceptionAndCartNotCleared() { + cart.getItems().add(item1Input); - assertThrows(NoSuchElementException.class, () -> cartService.archiveCart(customerId)); - verify(cartRepository).findByCustomerIdAndArchived(customerId, false); - verify(cartRepository, never()).save(any()); - } + BigDecimal subTotal = item1Input.getUnitPrice(); + String formattedSubTotal = String.format("%.2f", subTotal); + BigDecimal bigZero = BigDecimal.ZERO; + String formattedBigZero = String.format("%.2f", bigZero); + cart.setSubTotal(new BigDecimal(formattedSubTotal)); + cart.setTotalPrice(new BigDecimal(formattedSubTotal)); + cart.setDiscountAmount(new BigDecimal(formattedBigZero)); - @Test - void unarchiveCart_archivedCart_unarchivesCart() { - cart.setArchived(true); - when(cartRepository.findByCustomerIdAndArchived(customerId, true)).thenReturn(Optional.of(cart)); - when(cartRepository.save(any(Cart.class))).thenReturn(cart); + doThrow(new RuntimeException("RabbitMQ publish error")).when(rabbitTemplate) + .convertAndSend(eq(exchangeName), eq(checkoutRoutingKey), any(OrderRequest.class)); - Cart result = cartService.unarchiveCart(customerId); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> cartService.checkoutCart(customerId)); - assertFalse(result.isArchived()); - verify(cartRepository).findByCustomerIdAndArchived(customerId, true); - verify(cartRepository).save(cart); + assertTrue(ex.getMessage().contains("Checkout process failed: Could not publish event.")); + verify(cartRepository, never()).save(any(Cart.class)); + + assertEquals(1, cart.getItems().size()); + assertEquals(new BigDecimal(formattedSubTotal), cart.getSubTotal()); } @Test - void unarchiveCart_noArchivedCart_throwsNoSuchElementException() { - when(cartRepository.findByCustomerIdAndArchived(customerId, true)).thenReturn(Optional.empty()); - - assertThrows(NoSuchElementException.class, () -> cartService.unarchiveCart(customerId)); - verify(cartRepository).findByCustomerIdAndArchived(customerId, true); - verify(cartRepository, never()).save(any()); + void getCartByCustomerId_cartExists_returnsCart() { + Cart result = cartService.getCartByCustomerId(customerId); + assertEquals(cart, result); + verify(cartRepository).findByCustomerId(customerId); } @Test - void checkoutCart_validCart_sendsToOrderServiceAndClearsCart() { - cart.getItems().add(new CartItem(productId, 1)); - when(cartRepository.findByCustomerIdAndArchived(customerId, false)).thenReturn(Optional.of(cart)); - when(cartRepository.save(any(Cart.class))).thenReturn(cart); - when(restTemplate.postForObject(eq(orderServiceUrl + "/orders"), any(OrderRequest.class), eq(Void.class))) - .thenReturn(null); + void getCartByCustomerId_cartNotFound_throwsGlobalHandlerException() { + String nonExistentCustId = "ghost"; + when(cartRepository.findByCustomerId(eq(nonExistentCustId))).thenReturn(Optional.empty()); - Cart result = cartService.checkoutCart(customerId); + GlobalHandlerException exception = assertThrows(GlobalHandlerException.class, + () -> cartService.getCartByCustomerId(nonExistentCustId)); + assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); + assertEquals("Cart not found", exception.getMessage()); + verify(cartRepository).findByCustomerId(eq(nonExistentCustId)); + } - assertTrue(result.getItems().isEmpty()); - verify(cartRepository).findByCustomerIdAndArchived(customerId, false); - verify(restTemplate).postForObject(eq(orderServiceUrl + "/orders"), any(OrderRequest.class), eq(Void.class)); + @Test + void archiveCart_activeCart_archivesCart() { + Cart result = cartService.archiveCart(customerId); + assertTrue(result.isArchived()); verify(cartRepository).save(cart); } @Test - void checkoutCart_noActiveCart_throwsNoSuchElementException() { + void archiveCart_noActiveCart_throwsNoSuchElementException() { when(cartRepository.findByCustomerIdAndArchived(customerId, false)).thenReturn(Optional.empty()); - - assertThrows(NoSuchElementException.class, () -> cartService.checkoutCart(customerId)); - verify(cartRepository).findByCustomerIdAndArchived(customerId, false); - verify(restTemplate, never()).postForObject(any(), any(), any()); - verify(cartRepository, never()).save(any()); + assertThrows(NoSuchElementException.class, () -> cartService.archiveCart(customerId)); } @Test - void checkoutCart_orderServiceFails_throwsRuntimeException() { - cart.getItems().add(new CartItem(productId, 1)); - when(cartRepository.findByCustomerIdAndArchived(customerId, false)).thenReturn(Optional.of(cart)); - when(restTemplate.postForObject(eq(orderServiceUrl + "/orders"), any(OrderRequest.class), eq(Void.class))) - .thenThrow(new RuntimeException("Order Service error")); + void unarchiveCart_archivedCart_unarchivesCart() { + Cart archivedCart = createNewTestCart(customerId, "archivedCrt"); + archivedCart.setArchived(true); + when(cartRepository.findByCustomerIdAndArchived(customerId, true)).thenReturn(Optional.of(archivedCart)); - RuntimeException exception = assertThrows(RuntimeException.class, () -> cartService.checkoutCart(customerId)); - assertEquals("Error communicating with Order Service", exception.getMessage()); - verify(cartRepository).findByCustomerIdAndArchived(customerId, false); - verify(restTemplate).postForObject(eq(orderServiceUrl + "/orders"), any(OrderRequest.class), eq(Void.class)); - verify(cartRepository, never()).save(any()); + Cart result = cartService.unarchiveCart(customerId); + + assertFalse(result.isArchived()); + verify(cartRepository).save(archivedCart); } } \ No newline at end of file diff --git a/src/test/java/service/PromoCodeServiceTest.java b/src/test/java/service/PromoCodeServiceTest.java new file mode 100644 index 0000000..aa6116a --- /dev/null +++ b/src/test/java/service/PromoCodeServiceTest.java @@ -0,0 +1,192 @@ +package service; + +import cart.exception.GlobalHandlerException; +import cart.model.PromoCode; +import cart.repository.PromoCodeRepository; +import cart.service.PromoCodeService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PromoCodeServiceTest { + + @Mock + private PromoCodeRepository promoCodeRepository; + + @InjectMocks + private PromoCodeService promoCodeService; + + private PromoCode promoCode; + + @BeforeEach + void setUp() { + promoCode = new PromoCode(); + promoCode.setId("promo1"); + promoCode.setCode("SAVE10"); + promoCode.setDescription("10% off"); + promoCode.setDiscountType(PromoCode.DiscountType.PERCENTAGE); + promoCode.setDiscountValue(new BigDecimal("10.00")); + promoCode.setActive(true); + promoCode.setExpiryDate(Instant.now().plusSeconds(3600)); + promoCode.setMinimumPurchaseAmount(new BigDecimal("50.00")); + } + + @Test + void createOrUpdatePromoCode_newPromoCode_createsAndSaves() { + PromoCode input = new PromoCode(); + input.setCode("NEWCODE"); + input.setDiscountType(PromoCode.DiscountType.FIXED_AMOUNT); + input.setDiscountValue(new BigDecimal("5.00")); + input.setActive(true); + + when(promoCodeRepository.findByCode("NEWCODE")).thenReturn(Optional.empty()); + when(promoCodeRepository.save(any(PromoCode.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + PromoCode result = promoCodeService.createOrUpdatePromoCode(input); + + assertEquals("NEWCODE", result.getCode()); + assertEquals(PromoCode.DiscountType.FIXED_AMOUNT, result.getDiscountType()); + assertEquals(new BigDecimal("5.00"), result.getDiscountValue()); + assertTrue(result.isActive()); + verify(promoCodeRepository).findByCode("NEWCODE"); + verify(promoCodeRepository).save(input); + } + + @Test + void createOrUpdatePromoCode_existingPromoCode_updatesAndSaves() { + PromoCode input = new PromoCode(); + input.setCode("save10"); // Mixed case to test uppercase conversion + input.setDiscountType(PromoCode.DiscountType.FIXED_AMOUNT); + input.setDiscountValue(new BigDecimal("15.00")); + input.setActive(false); + + when(promoCodeRepository.findByCode("SAVE10")).thenReturn(Optional.of(promoCode)); + when(promoCodeRepository.save(any(PromoCode.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + PromoCode result = promoCodeService.createOrUpdatePromoCode(input); + + assertEquals("promo1", result.getId()); // Preserves existing ID + assertEquals("SAVE10", result.getCode()); + assertEquals(PromoCode.DiscountType.FIXED_AMOUNT, result.getDiscountType()); + assertEquals(new BigDecimal("15.00"), result.getDiscountValue()); + assertFalse(result.isActive()); + verify(promoCodeRepository).findByCode("SAVE10"); + verify(promoCodeRepository).save(input); + } + + @Test + void findByCode_existingCode_returnsPromoCode() { + when(promoCodeRepository.findByCode("SAVE10")).thenReturn(Optional.of(promoCode)); + + Optional result = promoCodeService.findByCode("save10"); // Mixed case + + assertTrue(result.isPresent()); + assertEquals(promoCode, result.get()); + verify(promoCodeRepository).findByCode("SAVE10"); + } + + @Test + void findByCode_nonExistentCode_returnsEmpty() { + when(promoCodeRepository.findByCode("FAKECODE")).thenReturn(Optional.empty()); + + Optional result = promoCodeService.findByCode("fakecode"); + + assertTrue(result.isEmpty()); + verify(promoCodeRepository).findByCode("FAKECODE"); + } + + @Test + void findAll_promoCodesExist_returnsList() { + List promoCodes = List.of(promoCode); + when(promoCodeRepository.findAll()).thenReturn(promoCodes); + + List result = promoCodeService.findAll(); + + assertEquals(1, result.size()); + assertEquals(promoCode, result.get(0)); + verify(promoCodeRepository).findAll(); + } + + @Test + void findAll_noPromoCodes_returnsEmptyList() { + when(promoCodeRepository.findAll()).thenReturn(Collections.emptyList()); + + List result = promoCodeService.findAll(); + + assertTrue(result.isEmpty()); + verify(promoCodeRepository).findAll(); + } + + @Test + void deletePromoCode_existingCode_deletesPromoCode() { + when(promoCodeRepository.findByCode("SAVE10")).thenReturn(Optional.of(promoCode)); + doNothing().when(promoCodeRepository).delete(promoCode); + + promoCodeService.deletePromoCode("save10"); + + verify(promoCodeRepository).findByCode("SAVE10"); + verify(promoCodeRepository).delete(promoCode); + } + + @Test + void deletePromoCode_nonExistentCode_throwsGlobalHandlerException() { + when(promoCodeRepository.findByCode("FAKECODE")).thenReturn(Optional.empty()); + + GlobalHandlerException ex = assertThrows(GlobalHandlerException.class, + () -> promoCodeService.deletePromoCode("fakecode")); + + assertEquals(HttpStatus.NOT_FOUND, ex.getStatus()); + assertEquals("Promo code not found: fakecode", ex.getMessage()); + verify(promoCodeRepository).findByCode("FAKECODE"); + verify(promoCodeRepository, never()).delete(any()); + } + + @Test + void getActivePromoCode_activePromoCode_returnsPromoCode() { + when(promoCodeRepository.findByCode("SAVE10")).thenReturn(Optional.of(promoCode)); + + Optional result = promoCodeService.getActivePromoCode("save10"); + + assertTrue(result.isPresent()); + assertEquals(promoCode, result.get()); + verify(promoCodeRepository).findByCode("SAVE10"); + } + + @Test + void getActivePromoCode_inactivePromoCode_returnsEmpty() { + PromoCode inactivePromo = new PromoCode(); + inactivePromo.setCode("INACTIVE"); + inactivePromo.setActive(false); + when(promoCodeRepository.findByCode("INACTIVE")).thenReturn(Optional.of(inactivePromo)); + + Optional result = promoCodeService.getActivePromoCode("inactive"); + + assertTrue(result.isEmpty()); + verify(promoCodeRepository).findByCode("INACTIVE"); + } + + @Test + void getActivePromoCode_nonExistentCode_returnsEmpty() { + when(promoCodeRepository.findByCode("FAKECODE")).thenReturn(Optional.empty()); + + Optional result = promoCodeService.getActivePromoCode("fakecode"); + + assertTrue(result.isEmpty()); + verify(promoCodeRepository).findByCode("FAKECODE"); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index a38ed3d..2fb525b 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,4 +1,14 @@ spring.data.mongodb.uri=mongodb://localhost:27017/testdb spring.data.mongodb.database=testdb spring.main.banner-mode= off -spring.main.log-startup-info=false \ No newline at end of file +spring.main.log-startup-info=false +# RabbitMQ Configuration +spring.rabbitmq.host=localhost +spring.rabbitmq.port=5672 +spring.rabbitmq.username=guest # Use appropriate credentials +spring.rabbitmq.password=guest # Use appropriate credentials +# spring.rabbitmq.virtual-host=/ # Optional + +# Custom properties for exchange/routing keys +rabbitmq.exchange.name=cart.events +rabbitmq.routing.key.checkout=order.checkout.initiate