From 4995ca82655812811eb91ae7b3737f4c3940bbb6 Mon Sep 17 00:00:00 2001 From: Leung Cheng Date: Mon, 9 Dec 2024 17:45:02 +0800 Subject: [PATCH 1/8] Add test for rollback behavior --- .../domain/order/OrderServiceTest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java b/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java index 869e399..53c5f20 100644 --- a/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java +++ b/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java @@ -107,6 +107,9 @@ void shouldRejectCreateOrderWithInsufficientBalance() { IllegalArgumentException.class, () -> orderService.createOrder(buyer.getId(), purchaseItems)); assertEquals("Insufficient balance", exception.getMessage()); + + // product quantity should not be reduced + assertEquals(999, productRepository.findById(product.getId()).orElseThrow().getQuantity()); } @Test @@ -125,6 +128,10 @@ void shouldRejectOrderWithInsufficientProductQuantity() { IllegalArgumentException.class, () -> orderService.createOrder(buyer.getId(), purchaseItems)); assertEquals("Insufficient stock for product: " + product.getId(), exception.getMessage()); + + // buyer balance should not be reduced + assertEquals( + new BigDecimal(999), userRepository.findById(buyer.getId()).orElseThrow().getBalance()); } @Test From 8638b0dbcc95a016c3fea7377a0b3df839a65c64 Mon Sep 17 00:00:00 2001 From: Leung Cheng Date: Mon, 9 Dec 2024 20:39:06 +0800 Subject: [PATCH 2/8] Update to make the transaction work --- .../spring_simple_backend/domain/User.java | 1 + .../domain/order/OrderService.java | 32 ++++++------ .../domain/order/PurchaseItems.java | 2 +- .../domain/order/OrderServiceTest.java | 49 ++++++++++--------- 4 files changed, 43 insertions(+), 41 deletions(-) diff --git a/src/main/java/com/leungcheng/spring_simple_backend/domain/User.java b/src/main/java/com/leungcheng/spring_simple_backend/domain/User.java index bcec398..c0ffdad 100644 --- a/src/main/java/com/leungcheng/spring_simple_backend/domain/User.java +++ b/src/main/java/com/leungcheng/spring_simple_backend/domain/User.java @@ -75,6 +75,7 @@ public User.Builder toBuilder() { @NotBlank private String password; @Min(0) + @Column(precision = 19, scale = 10) private BigDecimal balance; @Override diff --git a/src/main/java/com/leungcheng/spring_simple_backend/domain/order/OrderService.java b/src/main/java/com/leungcheng/spring_simple_backend/domain/order/OrderService.java index 155dbc3..983e8c0 100644 --- a/src/main/java/com/leungcheng/spring_simple_backend/domain/order/OrderService.java +++ b/src/main/java/com/leungcheng/spring_simple_backend/domain/order/OrderService.java @@ -8,35 +8,32 @@ import java.math.BigDecimal; import java.util.Map; import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @Service public class OrderService { - private final UserRepository userRepository; - private final ProductRepository productRepository; - private final OrderRepository orderRepository; - - public OrderService( - UserRepository userRepository, - ProductRepository productRepository, - OrderRepository orderRepository) { - this.userRepository = userRepository; - this.productRepository = productRepository; - this.orderRepository = orderRepository; + private @Autowired UserRepository userRepository; + private @Autowired ProductRepository productRepository; + private @Autowired OrderRepository orderRepository; + + public static class CreateOrderException extends IllegalArgumentException { + public CreateOrderException(String message) { + super(message); + } } @Transactional(isolation = Isolation.SERIALIZABLE) public Order createOrder(String buyerUserId, PurchaseItems purchaseItems) { User buyer = - getUser(buyerUserId) - .orElseThrow(() -> new IllegalArgumentException("Buyer does not exist")); + getUser(buyerUserId).orElseThrow(() -> new CreateOrderException("Buyer does not exist")); BigDecimal totalCost = processPurchaseItems(purchaseItems); if (buyer.getBalance().compareTo(totalCost) < 0) { - throw new IllegalArgumentException("Insufficient balance"); + throw new CreateOrderException("Insufficient balance"); } saveNewBalance(buyer, buyer.getBalance().subtract(totalCost)); @@ -60,7 +57,7 @@ private Order addNewOrder(String buyerUserId, PurchaseItems purchaseItems) { private BigDecimal processPurchaseItems(PurchaseItems purchaseItems) { ImmutableMap productIdToQuantity = purchaseItems.getProductIdToQuantity(); if (productIdToQuantity.isEmpty()) { - throw new IllegalArgumentException("Purchase items cannot be empty"); + throw new CreateOrderException("Purchase items cannot be empty"); } BigDecimal totalCost = BigDecimal.ZERO; @@ -80,7 +77,7 @@ private BigDecimal processPurchaseItems(PurchaseItems purchaseItems) { private void reduceProductStock(Product product, int purchaseQuantity) { if (purchaseQuantity > product.getQuantity()) { - throw new IllegalArgumentException("Insufficient stock for product: " + product.getId()); + throw new CreateOrderException("Insufficient stock for product: " + product.getId()); } int newQuantity = product.getQuantity() - purchaseQuantity; Product updatedProduct = product.toBuilder().quantity(newQuantity).build(); @@ -97,7 +94,6 @@ private void addProfitToSeller(Product product, int purchaseQuantity) { private Product getProduct(String productId) { return productRepository .findById(productId) - .orElseThrow( - () -> new IllegalArgumentException("Product: " + productId + " does not exist")); + .orElseThrow(() -> new CreateOrderException("Product: " + productId + " does not exist")); } } diff --git a/src/main/java/com/leungcheng/spring_simple_backend/domain/order/PurchaseItems.java b/src/main/java/com/leungcheng/spring_simple_backend/domain/order/PurchaseItems.java index f8c212d..727a825 100644 --- a/src/main/java/com/leungcheng/spring_simple_backend/domain/order/PurchaseItems.java +++ b/src/main/java/com/leungcheng/spring_simple_backend/domain/order/PurchaseItems.java @@ -6,7 +6,7 @@ @Embeddable public class PurchaseItems { - @ElementCollection + @ElementCollection(fetch = FetchType.EAGER) @CollectionTable( name = "purchase_items", joinColumns = {@JoinColumn(name = "order_id", referencedColumnName = "id")}) diff --git a/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java b/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java index 53c5f20..02d6783 100644 --- a/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java +++ b/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java @@ -6,22 +6,21 @@ import com.leungcheng.spring_simple_backend.domain.ProductRepository; import com.leungcheng.spring_simple_backend.domain.User; import com.leungcheng.spring_simple_backend.domain.UserRepository; +import com.leungcheng.spring_simple_backend.domain.order.OrderService.CreateOrderException; import java.math.BigDecimal; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.boot.test.context.SpringBootTest; -@ExtendWith(SpringExtension.class) -@DataJpaTest +@SpringBootTest class OrderServiceTest { private @Autowired UserRepository userRepository; private @Autowired ProductRepository productRepository; private @Autowired OrderRepository orderRepository; - private OrderService orderService; + private @Autowired OrderService orderService; private Product.Builder productBuilder() { return new Product.Builder() @@ -32,7 +31,10 @@ private Product.Builder productBuilder() { } private User.Builder userBuilder() { - return new User.Builder().username("user01").password("password").balance(new BigDecimal(100)); + return new User.Builder() + .username("user_" + UUID.randomUUID()) + .password("password") + .balance(new BigDecimal(100)); } private final User seedSeller = userBuilder().build(); @@ -44,7 +46,6 @@ void setUp() { productRepository.deleteAll(); userRepository.save(seedSeller); - orderService = new OrderService(userRepository, productRepository, orderRepository); } @Test @@ -55,9 +56,9 @@ void shouldRejectCreateOrderWithNonExistingBuyer() { PurchaseItems purchaseItems = new PurchaseItems(); purchaseItems.setPurchaseItem(product.getId(), 1); - IllegalArgumentException exception = + CreateOrderException exception = assertThrows( - IllegalArgumentException.class, + CreateOrderException.class, () -> orderService.createOrder("non_existing_buyer_id", purchaseItems)); assertEquals("Buyer does not exist", exception.getMessage()); } @@ -69,9 +70,9 @@ void shouldRejectCreateOrderWithEmptyPurchaseItems() { PurchaseItems purchaseItems = new PurchaseItems(); - IllegalArgumentException exception = + CreateOrderException exception = assertThrows( - IllegalArgumentException.class, + CreateOrderException.class, () -> orderService.createOrder(buyer.getId(), purchaseItems)); assertEquals("Purchase items cannot be empty", exception.getMessage()); } @@ -84,9 +85,9 @@ void shouldRejectCreateOrderWithNonExistingProduct() { PurchaseItems purchaseItems = new PurchaseItems(); purchaseItems.setPurchaseItem("non_existing_product_id", 1); - IllegalArgumentException exception = + CreateOrderException exception = assertThrows( - IllegalArgumentException.class, + CreateOrderException.class, () -> orderService.createOrder(buyer.getId(), purchaseItems)); assertEquals("Product: non_existing_product_id does not exist", exception.getMessage()); } @@ -102,9 +103,9 @@ void shouldRejectCreateOrderWithInsufficientBalance() { PurchaseItems purchaseItems = new PurchaseItems(); purchaseItems.setPurchaseItem(product.getId(), 2); - IllegalArgumentException exception = + CreateOrderException exception = assertThrows( - IllegalArgumentException.class, + CreateOrderException.class, () -> orderService.createOrder(buyer.getId(), purchaseItems)); assertEquals("Insufficient balance", exception.getMessage()); @@ -123,14 +124,14 @@ void shouldRejectOrderWithInsufficientProductQuantity() { PurchaseItems purchaseItems = new PurchaseItems(); purchaseItems.setPurchaseItem(product.getId(), 2); - IllegalArgumentException exception = + CreateOrderException exception = assertThrows( - IllegalArgumentException.class, + CreateOrderException.class, () -> orderService.createOrder(buyer.getId(), purchaseItems)); assertEquals("Insufficient stock for product: " + product.getId(), exception.getMessage()); // buyer balance should not be reduced - assertEquals( + assertBigDecimalEquals( new BigDecimal(999), userRepository.findById(buyer.getId()).orElseThrow().getBalance()); } @@ -167,7 +168,7 @@ void shouldReduceProductQuantityAndBuyerBalanceWhenOrderIsSuccessful() { assertEquals(8, productRepository.findById(product1.getId()).orElseThrow().getQuantity()); assertEquals(7, productRepository.findById(product2.getId()).orElseThrow().getQuantity()); - assertEquals( + assertBigDecimalEquals( new BigDecimal("4.1"), userRepository .findById(buyer.getId()) @@ -194,9 +195,9 @@ void shouldIncreaseSellersBalance() { orderService.createOrder(buyer.getId(), purchaseItems); - assertEquals( + assertBigDecimalEquals( new BigDecimal(15), userRepository.findById(seller1.getId()).orElseThrow().getBalance()); - assertEquals( + assertBigDecimalEquals( new BigDecimal(19), userRepository.findById(seller2.getId()).orElseThrow().getBalance()); } @@ -247,4 +248,8 @@ private void assertOrderEquals(Order expected, Order actual) { expected.getPurchaseItems().getProductIdToQuantity(), actual.getPurchaseItems().getProductIdToQuantity()); } + + private void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { + assertEquals(0, expected.compareTo(actual)); + } } From d202165dd314eabe83050a91a588a3d3ea1eb28e Mon Sep 17 00:00:00 2001 From: Leung Cheng Date: Tue, 10 Dec 2024 20:17:07 +0800 Subject: [PATCH 3/8] Add precision tests --- .../domain/PrecisionTest.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java diff --git a/src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java b/src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java new file mode 100644 index 0000000..0eccf54 --- /dev/null +++ b/src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java @@ -0,0 +1,51 @@ +package com.leungcheng.spring_simple_backend.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class PrecisionTest { + private @Autowired ProductRepository productRepository; + private @Autowired UserRepository userRepository; + + private static Product.Builder productBuilder() { + return new Product.Builder() + .name("Default Product") + .price(new BigDecimal("0.1")) + .userId("user_01") + .quantity(1); + } + + private static User.Builder userBuilder() { + return new User.Builder() + .username("default_user") + .password("default_password") + .balance(new BigDecimal("1.0")); + } + + @Test + void shouldProductKeepPrecisionAfterSavingToRepository() { + Product product = productBuilder().price(new BigDecimal("123456.78912")).build(); + productRepository.save(product); + Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); + + assertBigDecimalEquals(product.getPrice(), savedProduct.getPrice()); + } + + @Test + void shouldUserKeepPrecisionAfterSavingToRepository() { + User user = userBuilder().balance(new BigDecimal("123456.78912")).build(); + userRepository.save(user); + User savedUser = userRepository.findById(user.getId()).orElseThrow(); + + assertBigDecimalEquals(user.getBalance(), savedUser.getBalance()); + } + + private void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { + assertEquals(0, expected.compareTo(actual)); + } +} From db999338d44cbd740caaec708c50c62cb1d61b0d Mon Sep 17 00:00:00 2001 From: Leung Cheng Date: Tue, 10 Dec 2024 20:17:22 +0800 Subject: [PATCH 4/8] Configure precision for product price --- .../com/leungcheng/spring_simple_backend/domain/Product.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/leungcheng/spring_simple_backend/domain/Product.java b/src/main/java/com/leungcheng/spring_simple_backend/domain/Product.java index 0252dd5..e08e91d 100644 --- a/src/main/java/com/leungcheng/spring_simple_backend/domain/Product.java +++ b/src/main/java/com/leungcheng/spring_simple_backend/domain/Product.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.leungcheng.spring_simple_backend.validation.ObjectValidator; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; @@ -71,6 +72,7 @@ public Builder toBuilder() { @NotBlank private String name; + @Column(precision = 19, scale = 5) @Min(0) private BigDecimal price; From 632a2d89f61f8bb1b00a353290b94f44732eea51 Mon Sep 17 00:00:00 2001 From: Leung Cheng Date: Tue, 10 Dec 2024 20:23:51 +0800 Subject: [PATCH 5/8] Refactor the assertBigDecimalEquals --- .../spring_simple_backend/domain/PrecisionTest.java | 6 +----- .../domain/order/OrderServiceTest.java | 5 +---- .../testutil/CustomAssertions.java | 11 +++++++++++ 3 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 src/test/java/com/leungcheng/spring_simple_backend/testutil/CustomAssertions.java diff --git a/src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java b/src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java index 0eccf54..e6240d9 100644 --- a/src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java +++ b/src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java @@ -1,6 +1,6 @@ package com.leungcheng.spring_simple_backend.domain; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static com.leungcheng.spring_simple_backend.testutil.CustomAssertions.assertBigDecimalEquals; import java.math.BigDecimal; import org.junit.jupiter.api.Test; @@ -44,8 +44,4 @@ void shouldUserKeepPrecisionAfterSavingToRepository() { assertBigDecimalEquals(user.getBalance(), savedUser.getBalance()); } - - private void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { - assertEquals(0, expected.compareTo(actual)); - } } diff --git a/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java b/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java index 02d6783..e7652cb 100644 --- a/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java +++ b/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java @@ -1,5 +1,6 @@ package com.leungcheng.spring_simple_backend.domain.order; +import static com.leungcheng.spring_simple_backend.testutil.CustomAssertions.assertBigDecimalEquals; import static org.junit.jupiter.api.Assertions.*; import com.leungcheng.spring_simple_backend.domain.Product; @@ -248,8 +249,4 @@ private void assertOrderEquals(Order expected, Order actual) { expected.getPurchaseItems().getProductIdToQuantity(), actual.getPurchaseItems().getProductIdToQuantity()); } - - private void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { - assertEquals(0, expected.compareTo(actual)); - } } diff --git a/src/test/java/com/leungcheng/spring_simple_backend/testutil/CustomAssertions.java b/src/test/java/com/leungcheng/spring_simple_backend/testutil/CustomAssertions.java new file mode 100644 index 0000000..abb8057 --- /dev/null +++ b/src/test/java/com/leungcheng/spring_simple_backend/testutil/CustomAssertions.java @@ -0,0 +1,11 @@ +package com.leungcheng.spring_simple_backend.testutil; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigDecimal; + +public class CustomAssertions { + public static void assertBigDecimalEquals(BigDecimal expected, BigDecimal actual) { + assertEquals(0, expected.compareTo(actual)); + } +} From be872d0b9852cc1b70f091e2821721ad10d1c59f Mon Sep 17 00:00:00 2001 From: Leung Cheng Date: Tue, 10 Dec 2024 20:42:18 +0800 Subject: [PATCH 6/8] Refactor builders function --- .../domain/PrecisionTest.java | 17 ++-------- .../domain/ProductTest.java | 9 +----- .../domain/UserTest.java | 8 +---- .../domain/order/OrderServiceTest.java | 32 +++++++++---------- .../testutil/DefaultBuilders.java | 22 +++++++++++++ 5 files changed, 41 insertions(+), 47 deletions(-) create mode 100644 src/test/java/com/leungcheng/spring_simple_backend/testutil/DefaultBuilders.java diff --git a/src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java b/src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java index e6240d9..bc46eef 100644 --- a/src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java +++ b/src/test/java/com/leungcheng/spring_simple_backend/domain/PrecisionTest.java @@ -1,6 +1,8 @@ package com.leungcheng.spring_simple_backend.domain; import static com.leungcheng.spring_simple_backend.testutil.CustomAssertions.assertBigDecimalEquals; +import static com.leungcheng.spring_simple_backend.testutil.DefaultBuilders.productBuilder; +import static com.leungcheng.spring_simple_backend.testutil.DefaultBuilders.userBuilder; import java.math.BigDecimal; import org.junit.jupiter.api.Test; @@ -12,21 +14,6 @@ class PrecisionTest { private @Autowired ProductRepository productRepository; private @Autowired UserRepository userRepository; - private static Product.Builder productBuilder() { - return new Product.Builder() - .name("Default Product") - .price(new BigDecimal("0.1")) - .userId("user_01") - .quantity(1); - } - - private static User.Builder userBuilder() { - return new User.Builder() - .username("default_user") - .password("default_password") - .balance(new BigDecimal("1.0")); - } - @Test void shouldProductKeepPrecisionAfterSavingToRepository() { Product product = productBuilder().price(new BigDecimal("123456.78912")).build(); diff --git a/src/test/java/com/leungcheng/spring_simple_backend/domain/ProductTest.java b/src/test/java/com/leungcheng/spring_simple_backend/domain/ProductTest.java index c1a1461..c9dd260 100644 --- a/src/test/java/com/leungcheng/spring_simple_backend/domain/ProductTest.java +++ b/src/test/java/com/leungcheng/spring_simple_backend/domain/ProductTest.java @@ -1,5 +1,6 @@ package com.leungcheng.spring_simple_backend.domain; +import static com.leungcheng.spring_simple_backend.testutil.DefaultBuilders.productBuilder; import static org.junit.jupiter.api.Assertions.*; import com.leungcheng.spring_simple_backend.validation.ObjectValidator; @@ -8,14 +9,6 @@ class ProductTest { - private static Product.Builder productBuilder() { - return new Product.Builder() - .name("Default Product") - .price(new BigDecimal("0.1")) - .userId("user_01") - .quantity(1); - } - @Test void shouldCreateProduct() { Product product = diff --git a/src/test/java/com/leungcheng/spring_simple_backend/domain/UserTest.java b/src/test/java/com/leungcheng/spring_simple_backend/domain/UserTest.java index 73e22d9..36097ea 100644 --- a/src/test/java/com/leungcheng/spring_simple_backend/domain/UserTest.java +++ b/src/test/java/com/leungcheng/spring_simple_backend/domain/UserTest.java @@ -1,5 +1,6 @@ package com.leungcheng.spring_simple_backend.domain; +import static com.leungcheng.spring_simple_backend.testutil.DefaultBuilders.userBuilder; import static org.junit.jupiter.api.Assertions.*; import com.leungcheng.spring_simple_backend.validation.ObjectValidator; @@ -7,13 +8,6 @@ import org.junit.jupiter.api.Test; class UserTest { - private static User.Builder userBuilder() { - return new User.Builder() - .username("default_user") - .password("default_password") - .balance(new BigDecimal("1.0")); - } - @Test void shouldCreateUser() { User user = diff --git a/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java b/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java index e7652cb..0ff47d2 100644 --- a/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java +++ b/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java @@ -8,6 +8,7 @@ import com.leungcheng.spring_simple_backend.domain.User; import com.leungcheng.spring_simple_backend.domain.UserRepository; import com.leungcheng.spring_simple_backend.domain.order.OrderService.CreateOrderException; +import com.leungcheng.spring_simple_backend.testutil.DefaultBuilders; import java.math.BigDecimal; import java.util.List; import java.util.UUID; @@ -31,14 +32,11 @@ private Product.Builder productBuilder() { .quantity(10); } - private User.Builder userBuilder() { - return new User.Builder() - .username("user_" + UUID.randomUUID()) - .password("password") - .balance(new BigDecimal(100)); + private User.Builder randomUsernameUserBuilder() { + return DefaultBuilders.userBuilder().username(UUID.randomUUID().toString()); } - private final User seedSeller = userBuilder().build(); + private final User seedSeller = randomUsernameUserBuilder().build(); @BeforeEach void setUp() { @@ -66,7 +64,7 @@ void shouldRejectCreateOrderWithNonExistingBuyer() { @Test void shouldRejectCreateOrderWithEmptyPurchaseItems() { - User buyer = userBuilder().build(); + User buyer = randomUsernameUserBuilder().build(); userRepository.save(buyer); PurchaseItems purchaseItems = new PurchaseItems(); @@ -80,7 +78,7 @@ void shouldRejectCreateOrderWithEmptyPurchaseItems() { @Test void shouldRejectCreateOrderWithNonExistingProduct() { - User buyer = userBuilder().build(); + User buyer = randomUsernameUserBuilder().build(); userRepository.save(buyer); PurchaseItems purchaseItems = new PurchaseItems(); @@ -95,7 +93,7 @@ void shouldRejectCreateOrderWithNonExistingProduct() { @Test void shouldRejectCreateOrderWithInsufficientBalance() { - User buyer = userBuilder().balance(new BigDecimal("9.99999")).build(); + User buyer = randomUsernameUserBuilder().balance(new BigDecimal("9.99999")).build(); userRepository.save(buyer); Product product = productBuilder().price(new BigDecimal(5)).quantity(999).build(); @@ -116,7 +114,7 @@ void shouldRejectCreateOrderWithInsufficientBalance() { @Test void shouldRejectOrderWithInsufficientProductQuantity() { - User buyer = userBuilder().balance(new BigDecimal(999)).build(); + User buyer = randomUsernameUserBuilder().balance(new BigDecimal(999)).build(); userRepository.save(buyer); Product product = productBuilder().quantity(1).price(BigDecimal.ONE).build(); @@ -138,7 +136,7 @@ void shouldRejectOrderWithInsufficientProductQuantity() { @Test void shouldNotThrowExceptionIfStockAndBuyerBalanceIsJustEnough() { - User buyer = userBuilder().balance(new BigDecimal(10)).build(); + User buyer = randomUsernameUserBuilder().balance(new BigDecimal(10)).build(); userRepository.save(buyer); Product product = productBuilder().quantity(1).price(new BigDecimal(10)).build(); @@ -152,7 +150,7 @@ void shouldNotThrowExceptionIfStockAndBuyerBalanceIsJustEnough() { @Test void shouldReduceProductQuantityAndBuyerBalanceWhenOrderIsSuccessful() { - User buyer = userBuilder().balance(new BigDecimal(25)).build(); + User buyer = randomUsernameUserBuilder().balance(new BigDecimal(25)).build(); userRepository.save(buyer); Product product1 = productBuilder().quantity(10).price(new BigDecimal("5.2")).build(); @@ -179,9 +177,9 @@ void shouldReduceProductQuantityAndBuyerBalanceWhenOrderIsSuccessful() { @Test void shouldIncreaseSellersBalance() { - User buyer = userBuilder().balance(new BigDecimal(999)).build(); - User seller1 = userBuilder().balance(new BigDecimal(5)).build(); - User seller2 = userBuilder().balance(new BigDecimal(10)).build(); + User buyer = randomUsernameUserBuilder().balance(new BigDecimal(999)).build(); + User seller1 = randomUsernameUserBuilder().balance(new BigDecimal(5)).build(); + User seller2 = randomUsernameUserBuilder().balance(new BigDecimal(10)).build(); userRepository.saveAll(List.of(buyer, seller1, seller2)); Product product1 = @@ -204,7 +202,7 @@ void shouldIncreaseSellersBalance() { @Test void shouldCreateOrder() { - User buyer = userBuilder().balance(new BigDecimal(999)).build(); + User buyer = randomUsernameUserBuilder().balance(new BigDecimal(999)).build(); userRepository.save(buyer); Product product1 = productBuilder().quantity(999).price(new BigDecimal(5)).build(); @@ -227,7 +225,7 @@ void shouldCreateOrder() { @Test void shouldEachCreatedOrderHasDifferentId() { - User buyer = userBuilder().balance(new BigDecimal(999)).build(); + User buyer = randomUsernameUserBuilder().balance(new BigDecimal(999)).build(); userRepository.save(buyer); Product product = productBuilder().quantity(999).price(new BigDecimal(5)).build(); diff --git a/src/test/java/com/leungcheng/spring_simple_backend/testutil/DefaultBuilders.java b/src/test/java/com/leungcheng/spring_simple_backend/testutil/DefaultBuilders.java new file mode 100644 index 0000000..7d6c085 --- /dev/null +++ b/src/test/java/com/leungcheng/spring_simple_backend/testutil/DefaultBuilders.java @@ -0,0 +1,22 @@ +package com.leungcheng.spring_simple_backend.testutil; + +import com.leungcheng.spring_simple_backend.domain.Product; +import com.leungcheng.spring_simple_backend.domain.User; +import java.math.BigDecimal; + +public class DefaultBuilders { + public static User.Builder userBuilder() { + return new User.Builder() + .username("default_user") + .password("default_password") + .balance(new BigDecimal("1.0")); + } + + public static Product.Builder productBuilder() { + return new Product.Builder() + .name("Default Product") + .price(new BigDecimal("0.1")) + .userId("user_01") + .quantity(1); + } +} From 7556edfcece6ed03c0ea6170c1f8afd837c1cf19 Mon Sep 17 00:00:00 2001 From: Leung Cheng Date: Tue, 10 Dec 2024 21:29:23 +0800 Subject: [PATCH 7/8] Add retry dependency --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 35f4ea0..2430223 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-security" implementation 'io.jsonwebtoken:jjwt-api:0.12.6' implementation "com.google.guava:guava:33.3.1-jre" + implementation "org.springframework.retry:spring-retry" runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' testImplementation 'org.springframework.boot:spring-boot-starter-test' From fbd6105663cb33d282d885f86b3b489fe7552e6b Mon Sep 17 00:00:00 2001 From: Leung Cheng Date: Tue, 10 Dec 2024 21:30:29 +0800 Subject: [PATCH 8/8] Retry for racing condition --- .../spring_simple_backend/RetryConfig.java | 8 +++++ .../domain/order/OrderService.java | 2 ++ .../domain/order/OrderServiceTest.java | 30 +++++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 src/main/java/com/leungcheng/spring_simple_backend/RetryConfig.java diff --git a/src/main/java/com/leungcheng/spring_simple_backend/RetryConfig.java b/src/main/java/com/leungcheng/spring_simple_backend/RetryConfig.java new file mode 100644 index 0000000..b14cdd5 --- /dev/null +++ b/src/main/java/com/leungcheng/spring_simple_backend/RetryConfig.java @@ -0,0 +1,8 @@ +package com.leungcheng.spring_simple_backend; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@EnableRetry +@Configuration +public class RetryConfig {} diff --git a/src/main/java/com/leungcheng/spring_simple_backend/domain/order/OrderService.java b/src/main/java/com/leungcheng/spring_simple_backend/domain/order/OrderService.java index 983e8c0..baafaed 100644 --- a/src/main/java/com/leungcheng/spring_simple_backend/domain/order/OrderService.java +++ b/src/main/java/com/leungcheng/spring_simple_backend/domain/order/OrderService.java @@ -9,6 +9,7 @@ import java.util.Map; import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +26,7 @@ public CreateOrderException(String message) { } } + @Retryable(noRetryFor = CreateOrderException.class) @Transactional(isolation = Isolation.SERIALIZABLE) public Order createOrder(String buyerUserId, PurchaseItems purchaseItems) { User buyer = diff --git a/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java b/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java index 0ff47d2..6b21dc0 100644 --- a/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java +++ b/src/test/java/com/leungcheng/spring_simple_backend/domain/order/OrderServiceTest.java @@ -240,6 +240,36 @@ void shouldEachCreatedOrderHasDifferentId() { assertNotEquals(order1.getId(), order2.getId()); } + @Test + void shouldAutoRetry_WhenOneThreadMayFailJustDueToRacing() { + User seller = randomUsernameUserBuilder().balance(new BigDecimal(999)).build(); + User buyer = randomUsernameUserBuilder().balance(new BigDecimal(999)).build(); + userRepository.saveAll(List.of(seller, buyer)); + + Product product = + productBuilder().quantity(2).price(new BigDecimal(5)).userId(seller.getId()).build(); + productRepository.save(product); + + PurchaseItems purchaseItems = new PurchaseItems(); + purchaseItems.setPurchaseItem(product.getId(), 1); + + // 2 threads try to buy the same product at the same time + Thread thread1 = new Thread(() -> orderService.createOrder(buyer.getId(), purchaseItems)); + Thread thread2 = new Thread(() -> orderService.createOrder(buyer.getId(), purchaseItems)); + + thread1.start(); + thread2.start(); + + try { + thread1.join(); + thread2.join(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + assertEquals(0, productRepository.findById(product.getId()).orElseThrow().getQuantity()); + } + private void assertOrderEquals(Order expected, Order actual) { assertEquals(expected.getId(), actual.getId()); assertEquals(expected.getBuyerUserId(), actual.getBuyerUserId());