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 extends GrantedAuthority> 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