diff --git a/pom.xml b/pom.xml index fc5bfeac..da3f7cc2 100644 --- a/pom.xml +++ b/pom.xml @@ -67,6 +67,11 @@ spring-security-test test + + com.h2database + h2 + test + diff --git a/src/main/java/com/example/bankapp/config/AsyncConfig.java b/src/main/java/com/example/bankapp/config/AsyncConfig.java new file mode 100644 index 00000000..c0c342f4 --- /dev/null +++ b/src/main/java/com/example/bankapp/config/AsyncConfig.java @@ -0,0 +1,23 @@ +package com.example.bankapp.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("BankAsync-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/example/bankapp/event/TransactionEvent.java b/src/main/java/com/example/bankapp/event/TransactionEvent.java new file mode 100644 index 00000000..478e3b92 --- /dev/null +++ b/src/main/java/com/example/bankapp/event/TransactionEvent.java @@ -0,0 +1,19 @@ +package com.example.bankapp.event; + +import java.math.BigDecimal; + +public class TransactionEvent { + private final BigDecimal amount; + private final String type; + private final Long accountId; + + public TransactionEvent(BigDecimal amount, String type, Long accountId) { + this.amount = amount; + this.type = type; + this.accountId = accountId; + } + + public BigDecimal getAmount() { return amount; } + public String getType() { return type; } + public Long getAccountId() { return accountId; } +} diff --git a/src/main/java/com/example/bankapp/event/TransactionEventListener.java b/src/main/java/com/example/bankapp/event/TransactionEventListener.java new file mode 100644 index 00000000..7c8302ad --- /dev/null +++ b/src/main/java/com/example/bankapp/event/TransactionEventListener.java @@ -0,0 +1,39 @@ +package com.example.bankapp.event; + +import com.example.bankapp.model.Account; +import com.example.bankapp.model.Transaction; +import com.example.bankapp.repository.AccountRepository; +import com.example.bankapp.repository.TransactionRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Component +public class TransactionEventListener { + + @Autowired + private TransactionRepository transactionRepository; + + @Autowired + private AccountRepository accountRepository; + + @Async + @EventListener + @Transactional + public void handleTransactionEvent(TransactionEvent event) { + Account account = accountRepository.findById(event.getAccountId()) + .orElseThrow(() -> new RuntimeException("Account not found")); + + Transaction transaction = new Transaction( + event.getAmount(), + event.getType(), + LocalDateTime.now(), + account + ); + transactionRepository.save(transaction); + } +} diff --git a/src/main/java/com/example/bankapp/service/AccountService.java b/src/main/java/com/example/bankapp/service/AccountService.java index 5d7d90ec..f0ccf406 100644 --- a/src/main/java/com/example/bankapp/service/AccountService.java +++ b/src/main/java/com/example/bankapp/service/AccountService.java @@ -1,10 +1,11 @@ package com.example.bankapp.service; +import com.example.bankapp.event.TransactionEvent; import com.example.bankapp.model.Account; -import com.example.bankapp.model.Transaction; import com.example.bankapp.repository.AccountRepository; import com.example.bankapp.repository.TransactionRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -12,9 +13,9 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; -import java.time.LocalDateTime; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -31,10 +32,14 @@ public class AccountService implements UserDetailsService { @Autowired private TransactionRepository transactionRepository; + @Autowired + private ApplicationEventPublisher eventPublisher; + public Account findAccountByUsername(String username) { return accountRepository.findByUsername(username).orElseThrow(() -> new RuntimeException("Account not found")); } + @Transactional public Account registerAccount(String username, String password) { if (accountRepository.findByUsername(username).isPresent()) { throw new RuntimeException("Username already exists"); @@ -47,20 +52,15 @@ public Account registerAccount(String username, String password) { return accountRepository.save(account); } - + @Transactional public void deposit(Account account, BigDecimal amount) { account.setBalance(account.getBalance().add(amount)); accountRepository.save(account); - Transaction transaction = new Transaction( - amount, - "Deposit", - LocalDateTime.now(), - account - ); - transactionRepository.save(transaction); + eventPublisher.publishEvent(new TransactionEvent(amount, "Deposit", account.getId())); } + @Transactional public void withdraw(Account account, BigDecimal amount) { if (account.getBalance().compareTo(amount) < 0) { throw new RuntimeException("Insufficient funds"); @@ -68,16 +68,10 @@ public void withdraw(Account account, BigDecimal amount) { account.setBalance(account.getBalance().subtract(amount)); accountRepository.save(account); - Transaction transaction = new Transaction( - amount, - "Withdrawal", - LocalDateTime.now(), - account - ); - transactionRepository.save(transaction); + eventPublisher.publishEvent(new TransactionEvent(amount, "Withdrawal", account.getId())); } - public List getTransactionHistory(Account account) { + public List getTransactionHistory(Account account) { return transactionRepository.findByAccountId(account.getId()); } @@ -100,6 +94,7 @@ public Collection authorities() { return Arrays.asList(new SimpleGrantedAuthority("USER")); } + @Transactional public void transferAmount(Account fromAccount, String toUsername, BigDecimal amount) { if (fromAccount.getBalance().compareTo(amount) < 0) { throw new RuntimeException("Insufficient funds"); @@ -116,22 +111,9 @@ public void transferAmount(Account fromAccount, String toUsername, BigDecimal am toAccount.setBalance(toAccount.getBalance().add(amount)); accountRepository.save(toAccount); - // Create transaction records for both accounts - Transaction debitTransaction = new Transaction( - amount, - "Transfer Out to " + toAccount.getUsername(), - LocalDateTime.now(), - fromAccount - ); - transactionRepository.save(debitTransaction); - - Transaction creditTransaction = new Transaction( - amount, - "Transfer In from " + fromAccount.getUsername(), - LocalDateTime.now(), - toAccount - ); - transactionRepository.save(creditTransaction); + // Publish transaction events for async audit record creation + eventPublisher.publishEvent(new TransactionEvent(amount, "Transfer Out to " + toAccount.getUsername(), fromAccount.getId())); + eventPublisher.publishEvent(new TransactionEvent(amount, "Transfer In from " + fromAccount.getUsername(), toAccount.getId())); } } diff --git a/src/test/java/com/example/bankapp/controller/BankControllerIntegrationTest.java b/src/test/java/com/example/bankapp/controller/BankControllerIntegrationTest.java new file mode 100644 index 00000000..fdecd6ee --- /dev/null +++ b/src/test/java/com/example/bankapp/controller/BankControllerIntegrationTest.java @@ -0,0 +1,153 @@ +package com.example.bankapp.controller; + +import com.example.bankapp.model.Account; +import com.example.bankapp.repository.AccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class BankControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private AccountRepository accountRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + accountRepository.deleteAll(); + + Account account = new Account(); + account.setUsername("testuser"); + account.setPassword(passwordEncoder.encode("password")); + account.setBalance(new BigDecimal("1000")); + accountRepository.save(account); + + Account recipient = new Account(); + recipient.setUsername("recipient"); + recipient.setPassword(passwordEncoder.encode("password")); + recipient.setBalance(new BigDecimal("500")); + accountRepository.save(recipient); + } + + @Test + @WithMockUser(username = "testuser") + void dashboard_authenticated() throws Exception { + mockMvc.perform(get("/dashboard")) + .andExpect(status().isOk()) + .andExpect(view().name("dashboard")) + .andExpect(model().attributeExists("account")); + } + + @Test + void dashboard_unauthenticated() throws Exception { + mockMvc.perform(get("/dashboard")) + .andExpect(status().is3xxRedirection()); + } + + @Test + @WithMockUser(username = "testuser") + void deposit_success() throws Exception { + mockMvc.perform(post("/deposit") + .param("amount", "500") + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dashboard")); + } + + @Test + @WithMockUser(username = "testuser") + void withdraw_success() throws Exception { + mockMvc.perform(post("/withdraw") + .param("amount", "500") + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dashboard")); + } + + @Test + @WithMockUser(username = "testuser") + void withdraw_insufficientFunds() throws Exception { + mockMvc.perform(post("/withdraw") + .param("amount", "5000") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(view().name("dashboard")) + .andExpect(model().attributeExists("error")); + } + + @Test + @WithMockUser(username = "testuser") + void transfer_success() throws Exception { + mockMvc.perform(post("/transfer") + .param("toUsername", "recipient") + .param("amount", "300") + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dashboard")); + } + + @Test + @WithMockUser(username = "testuser") + void transfer_recipientNotFound() throws Exception { + mockMvc.perform(post("/transfer") + .param("toUsername", "nonexistent") + .param("amount", "100") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(view().name("dashboard")) + .andExpect(model().attributeExists("error")); + } + + @Test + void register_success() throws Exception { + mockMvc.perform(post("/register") + .param("username", "newuser") + .param("password", "newpassword") + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login")); + } + + @Test + void register_duplicateUsername() throws Exception { + mockMvc.perform(post("/register") + .param("username", "testuser") + .param("password", "password") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(view().name("register")) + .andExpect(model().attributeExists("error")); + } + + @Test + @WithMockUser(username = "testuser") + void transactionHistory() throws Exception { + mockMvc.perform(get("/transactions")) + .andExpect(status().isOk()) + .andExpect(view().name("transactions")) + .andExpect(model().attributeExists("transactions")); + } +} diff --git a/src/test/java/com/example/bankapp/event/TransactionEventListenerTest.java b/src/test/java/com/example/bankapp/event/TransactionEventListenerTest.java new file mode 100644 index 00000000..1cb7ae73 --- /dev/null +++ b/src/test/java/com/example/bankapp/event/TransactionEventListenerTest.java @@ -0,0 +1,69 @@ +package com.example.bankapp.event; + +import com.example.bankapp.model.Account; +import com.example.bankapp.model.Transaction; +import com.example.bankapp.repository.AccountRepository; +import com.example.bankapp.repository.TransactionRepository; +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 java.math.BigDecimal; +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 TransactionEventListenerTest { + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private AccountRepository accountRepository; + + @InjectMocks + private TransactionEventListener listener; + + private Account testAccount; + + @BeforeEach + void setUp() { + testAccount = new Account(); + testAccount.setId(1L); + testAccount.setUsername("testuser"); + testAccount.setBalance(new BigDecimal("1000")); + } + + @Test + void handleTransactionEvent_createsTransaction() { + TransactionEvent event = new TransactionEvent(new BigDecimal("500"), "Deposit", 1L); + when(accountRepository.findById(1L)).thenReturn(Optional.of(testAccount)); + when(transactionRepository.save(any(Transaction.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + listener.handleTransactionEvent(event); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Transaction.class); + verify(transactionRepository).save(captor.capture()); + Transaction saved = captor.getValue(); + assertEquals(new BigDecimal("500"), saved.getAmount()); + assertEquals("Deposit", saved.getType()); + assertEquals(testAccount, saved.getAccount()); + assertNotNull(saved.getTimestamp()); + } + + @Test + void handleTransactionEvent_accountNotFound() { + TransactionEvent event = new TransactionEvent(new BigDecimal("500"), "Deposit", 99L); + when(accountRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(RuntimeException.class, () -> listener.handleTransactionEvent(event)); + verify(transactionRepository, never()).save(any(Transaction.class)); + } +} diff --git a/src/test/java/com/example/bankapp/service/AccountServiceTest.java b/src/test/java/com/example/bankapp/service/AccountServiceTest.java new file mode 100644 index 00000000..a66f1c7b --- /dev/null +++ b/src/test/java/com/example/bankapp/service/AccountServiceTest.java @@ -0,0 +1,186 @@ +package com.example.bankapp.service; + +import com.example.bankapp.event.TransactionEvent; +import com.example.bankapp.model.Account; +import com.example.bankapp.repository.AccountRepository; +import com.example.bankapp.repository.TransactionRepository; +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.context.ApplicationEventPublisher; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AccountServiceTest { + + @Mock + private AccountRepository accountRepository; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private AccountService accountService; + + private Account testAccount; + + @BeforeEach + void setUp() { + testAccount = new Account(); + testAccount.setId(1L); + testAccount.setUsername("testuser"); + testAccount.setPassword("encodedPassword"); + testAccount.setBalance(new BigDecimal("1000")); + testAccount.setTransactions(Collections.emptyList()); + } + + @Test + void registerAccount_success() { + when(accountRepository.findByUsername("newuser")).thenReturn(Optional.empty()); + when(passwordEncoder.encode("password")).thenReturn("encodedPassword"); + when(accountRepository.save(any(Account.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + Account result = accountService.registerAccount("newuser", "password"); + + assertNotNull(result); + assertEquals("newuser", result.getUsername()); + assertEquals("encodedPassword", result.getPassword()); + assertEquals(BigDecimal.ZERO, result.getBalance()); + verify(accountRepository).save(any(Account.class)); + } + + @Test + void registerAccount_duplicateUsername() { + when(accountRepository.findByUsername("testuser")).thenReturn(Optional.of(testAccount)); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> accountService.registerAccount("testuser", "password")); + assertEquals("Username already exists", exception.getMessage()); + verify(accountRepository, never()).save(any(Account.class)); + } + + @Test + void deposit_success() { + when(accountRepository.save(any(Account.class))).thenReturn(testAccount); + + accountService.deposit(testAccount, new BigDecimal("500")); + + assertEquals(new BigDecimal("1500"), testAccount.getBalance()); + verify(accountRepository).save(testAccount); + verify(eventPublisher).publishEvent(any(TransactionEvent.class)); + } + + @Test + void deposit_correctAmount() { + testAccount.setBalance(new BigDecimal("500")); + when(accountRepository.save(any(Account.class))).thenReturn(testAccount); + + accountService.deposit(testAccount, new BigDecimal("100")); + + assertEquals(new BigDecimal("600"), testAccount.getBalance()); + verify(accountRepository).save(testAccount); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(TransactionEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + TransactionEvent event = eventCaptor.getValue(); + assertEquals(new BigDecimal("100"), event.getAmount()); + assertEquals("Deposit", event.getType()); + assertEquals(1L, event.getAccountId()); + } + + @Test + void withdraw_success() { + when(accountRepository.save(any(Account.class))).thenReturn(testAccount); + + accountService.withdraw(testAccount, new BigDecimal("500")); + + assertEquals(new BigDecimal("500"), testAccount.getBalance()); + verify(accountRepository).save(testAccount); + verify(eventPublisher).publishEvent(any(TransactionEvent.class)); + } + + @Test + void withdraw_insufficientFunds() { + RuntimeException exception = assertThrows(RuntimeException.class, + () -> accountService.withdraw(testAccount, new BigDecimal("1500"))); + assertEquals("Insufficient funds", exception.getMessage()); + verify(accountRepository, never()).save(any(Account.class)); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void transferAmount_success() { + Account toAccount = new Account(); + toAccount.setId(2L); + toAccount.setUsername("recipient"); + toAccount.setPassword("encodedPassword"); + toAccount.setBalance(new BigDecimal("500")); + + when(accountRepository.findByUsername("recipient")).thenReturn(Optional.of(toAccount)); + when(accountRepository.save(any(Account.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + accountService.transferAmount(testAccount, "recipient", new BigDecimal("300")); + + assertEquals(new BigDecimal("700"), testAccount.getBalance()); + assertEquals(new BigDecimal("800"), toAccount.getBalance()); + verify(accountRepository, times(2)).save(any(Account.class)); + verify(eventPublisher, times(2)).publishEvent(any(TransactionEvent.class)); + } + + @Test + void transferAmount_insufficientFunds() { + RuntimeException exception = assertThrows(RuntimeException.class, + () -> accountService.transferAmount(testAccount, "recipient", new BigDecimal("1500"))); + assertEquals("Insufficient funds", exception.getMessage()); + verify(accountRepository, never()).save(any(Account.class)); + } + + @Test + void transferAmount_recipientNotFound() { + when(accountRepository.findByUsername("unknown")).thenReturn(Optional.empty()); + + RuntimeException exception = assertThrows(RuntimeException.class, + () -> accountService.transferAmount(testAccount, "unknown", new BigDecimal("100"))); + assertEquals("Recipient account not found", exception.getMessage()); + } + + @Test + void loadUserByUsername_success() { + when(accountRepository.findByUsername("testuser")).thenReturn(Optional.of(testAccount)); + + UserDetails userDetails = accountService.loadUserByUsername("testuser"); + + assertNotNull(userDetails); + assertEquals("testuser", userDetails.getUsername()); + assertEquals("encodedPassword", userDetails.getPassword()); + } + + @Test + void loadUserByUsername_notFound() { + when(accountRepository.findByUsername("unknown")).thenReturn(Optional.empty()); + + assertThrows(RuntimeException.class, + () -> accountService.loadUserByUsername("unknown")); + } +} diff --git a/src/test/java/com/example/bankapp/service/TransferConcurrencyTest.java b/src/test/java/com/example/bankapp/service/TransferConcurrencyTest.java new file mode 100644 index 00000000..6731d313 --- /dev/null +++ b/src/test/java/com/example/bankapp/service/TransferConcurrencyTest.java @@ -0,0 +1,97 @@ +package com.example.bankapp.service; + +import com.example.bankapp.model.Account; +import com.example.bankapp.repository.AccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +@ActiveProfiles("test") +class TransferConcurrencyTest { + + @Autowired + private AccountService accountService; + + @Autowired + private AccountRepository accountRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private Long accountAId; + private Long accountBId; + + @BeforeEach + void setUp() { + accountRepository.deleteAll(); + + Account accountA = new Account(); + accountA.setUsername("userA"); + accountA.setPassword(passwordEncoder.encode("password")); + accountA.setBalance(new BigDecimal("1000")); + accountA = accountRepository.save(accountA); + accountAId = accountA.getId(); + + Account accountB = new Account(); + accountB.setUsername("userB"); + accountB.setPassword(passwordEncoder.encode("password")); + accountB.setBalance(new BigDecimal("1000")); + accountB = accountRepository.save(accountB); + accountBId = accountB.getId(); + } + + @Test + void concurrentTransfers_maintainConsistency() throws InterruptedException { + int threadCount = 10; + BigDecimal transferAmount = new BigDecimal("10"); + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger failures = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + final int index = i; + executor.submit(() -> { + try { + Account from; + String toUsername; + if (index % 2 == 0) { + from = accountRepository.findById(accountAId).orElseThrow(); + toUsername = "userB"; + } else { + from = accountRepository.findById(accountBId).orElseThrow(); + toUsername = "userA"; + } + accountService.transferAmount(from, toUsername, transferAmount); + } catch (Exception e) { + failures.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executor.shutdown(); + + Account finalA = accountRepository.findById(accountAId).orElseThrow(); + Account finalB = accountRepository.findById(accountBId).orElseThrow(); + BigDecimal totalMoney = finalA.getBalance().add(finalB.getBalance()); + + assertEquals(new BigDecimal("2000").stripTrailingZeros(), totalMoney.stripTrailingZeros(), + "Total money in the system should be conserved (2000). " + + "A=" + finalA.getBalance() + ", B=" + finalB.getBalance() + + ", failures=" + failures.get()); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 00000000..293d140a --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,8 @@ +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true