From c12f9dc01c82922b0168f17629c82955ec094138 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:23:42 +0000 Subject: [PATCH 1/5] chore: add H2 test dependency, fix maven-compiler-plugin for Java 17, add test properties Co-Authored-By: Joao Esteves --- pom.xml | 9 +++++++-- src/test/resources/application-test.properties | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 src/test/resources/application-test.properties diff --git a/pom.xml b/pom.xml index fc5bfeac..f6c9bdc2 100644 --- a/pom.xml +++ b/pom.xml @@ -67,6 +67,11 @@ spring-security-test test + + com.h2database + h2 + test + @@ -80,8 +85,8 @@ maven-compiler-plugin 3.8.0 - 1.8 - 1.8 + 17 + 17 diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 00000000..550f3d4d --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,6 @@ +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true From 79834bd522344c42da8c59763773c38a5d9e279c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:25:02 +0000 Subject: [PATCH 2/5] feat: add comprehensive test suite for payment flows Co-Authored-By: Joao Esteves --- .../bankapp/BankappApplicationTests.java | 2 + .../BankControllerIntegrationTest.java | 150 +++++++++++++++++ .../bankapp/service/AccountServiceTest.java | 158 ++++++++++++++++++ .../AccountServiceTransactionSafetyTest.java | 75 +++++++++ .../resources/application-test.properties | 1 + 5 files changed, 386 insertions(+) create mode 100644 src/test/java/com/example/bankapp/controller/BankControllerIntegrationTest.java create mode 100644 src/test/java/com/example/bankapp/service/AccountServiceTest.java create mode 100644 src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java diff --git a/src/test/java/com/example/bankapp/BankappApplicationTests.java b/src/test/java/com/example/bankapp/BankappApplicationTests.java index 63c64e9d..6184e848 100644 --- a/src/test/java/com/example/bankapp/BankappApplicationTests.java +++ b/src/test/java/com/example/bankapp/BankappApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class BankappApplicationTests { @Test 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..6772679c --- /dev/null +++ b/src/test/java/com/example/bankapp/controller/BankControllerIntegrationTest.java @@ -0,0 +1,150 @@ +package com.example.bankapp.controller; + +import com.example.bankapp.model.Account; +import com.example.bankapp.service.AccountService; +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.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 AccountService accountService; + + @Test + void dashboard_unauthenticated_redirectsToLogin() throws Exception { + mockMvc.perform(get("/dashboard")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrlPattern("**/login")); + } + + @Test + void deposit_unauthenticated_redirectsToLogin() throws Exception { + mockMvc.perform(post("/deposit").param("amount", "100")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrlPattern("**/login")); + } + + @Test + void withdraw_unauthenticated_redirectsToLogin() throws Exception { + mockMvc.perform(post("/withdraw").param("amount", "100")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrlPattern("**/login")); + } + + @Test + void transfer_unauthenticated_redirectsToLogin() throws Exception { + mockMvc.perform(post("/transfer").param("toUsername", "other").param("amount", "100")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrlPattern("**/login")); + } + + @Test + void register_validCredentials_redirectsToLogin() throws Exception { + mockMvc.perform(post("/register") + .param("username", "newuser") + .param("password", "password123") + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login")); + } + + @Test + void register_duplicateUsername_showsError() throws Exception { + accountService.registerAccount("existing", "password123"); + + mockMvc.perform(post("/register") + .param("username", "existing") + .param("password", "password123") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(view().name("register")) + .andExpect(model().attributeExists("error")); + } + + @Test + @WithMockUser(username = "deposituser") + void deposit_authenticated_updatesBalance() throws Exception { + accountService.registerAccount("deposituser", "password123"); + + mockMvc.perform(post("/deposit") + .param("amount", "500") + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dashboard")); + + Account updated = accountService.findAccountByUsername("deposituser"); + assert updated.getBalance().compareTo(new BigDecimal("500")) == 0; + } + + @Test + @WithMockUser(username = "withdrawuser") + void withdraw_authenticated_insufficientFunds_showsError() throws Exception { + accountService.registerAccount("withdrawuser", "password123"); + + mockMvc.perform(post("/withdraw") + .param("amount", "1000") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(view().name("dashboard")) + .andExpect(model().attributeExists("error")); + } + + @Test + @WithMockUser(username = "sender") + void transfer_authenticated_happyPath() throws Exception { + accountService.registerAccount("sender", "password123"); + accountService.registerAccount("receiver", "password123"); + + Account sender = accountService.findAccountByUsername("sender"); + accountService.deposit(sender, new BigDecimal("1000")); + + mockMvc.perform(post("/transfer") + .param("toUsername", "receiver") + .param("amount", "300") + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dashboard")); + + Account updatedSender = accountService.findAccountByUsername("sender"); + Account updatedReceiver = accountService.findAccountByUsername("receiver"); + assert updatedSender.getBalance().compareTo(new BigDecimal("700")) == 0; + assert updatedReceiver.getBalance().compareTo(new BigDecimal("300")) == 0; + } + + @Test + @WithMockUser(username = "transferuser") + void transfer_authenticated_recipientNotFound_showsError() throws Exception { + accountService.registerAccount("transferuser", "password123"); + + Account user = accountService.findAccountByUsername("transferuser"); + accountService.deposit(user, new BigDecimal("1000")); + + mockMvc.perform(post("/transfer") + .param("toUsername", "nonexistent") + .param("amount", "100") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(view().name("dashboard")) + .andExpect(model().attributeExists("error")); + } +} 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..ef724611 --- /dev/null +++ b/src/test/java/com/example/bankapp/service/AccountServiceTest.java @@ -0,0 +1,158 @@ +package com.example.bankapp.service; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +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 AccountServiceTest { + + @Mock + private AccountRepository accountRepository; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private AccountService accountService; + + private Account account; + + @BeforeEach + void setUp() { + account = new Account(); + account.setId(1L); + account.setUsername("testuser"); + account.setPassword("encoded"); + account.setBalance(new BigDecimal("1000.00")); + } + + @Test + void deposit_increasesBalanceAndSavesTransaction() { + BigDecimal amount = new BigDecimal("500.00"); + + accountService.deposit(account, amount); + + assertEquals(new BigDecimal("1500.00"), account.getBalance()); + verify(accountRepository).save(account); + verify(transactionRepository).save(any(Transaction.class)); + } + + @Test + void withdraw_decreasesBalanceAndSavesTransaction() { + BigDecimal amount = new BigDecimal("300.00"); + + accountService.withdraw(account, amount); + + assertEquals(new BigDecimal("700.00"), account.getBalance()); + verify(accountRepository).save(account); + verify(transactionRepository).save(any(Transaction.class)); + } + + @Test + void withdraw_insufficientFunds_throwsException() { + BigDecimal amount = new BigDecimal("2000.00"); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> accountService.withdraw(account, amount)); + assertEquals("Insufficient funds", ex.getMessage()); + } + + @Test + void withdraw_exactBalance_succeeds() { + BigDecimal amount = new BigDecimal("1000.00"); + + accountService.withdraw(account, amount); + + assertEquals(BigDecimal.ZERO.setScale(2), account.getBalance().setScale(2)); + verify(accountRepository).save(account); + verify(transactionRepository).save(any(Transaction.class)); + } + + @Test + void transferAmount_happyPath() { + Account recipient = new Account(); + recipient.setId(2L); + recipient.setUsername("recipient"); + recipient.setBalance(new BigDecimal("500.00")); + + when(accountRepository.findByUsername("recipient")).thenReturn(Optional.of(recipient)); + + accountService.transferAmount(account, "recipient", new BigDecimal("200.00")); + + assertEquals(new BigDecimal("800.00"), account.getBalance()); + assertEquals(new BigDecimal("700.00"), recipient.getBalance()); + verify(accountRepository, times(2)).save(any(Account.class)); + verify(transactionRepository, times(2)).save(any(Transaction.class)); + } + + @Test + void transferAmount_insufficientFunds_throwsException() { + RuntimeException ex = assertThrows(RuntimeException.class, + () -> accountService.transferAmount(account, "recipient", new BigDecimal("5000.00"))); + assertEquals("Insufficient funds", ex.getMessage()); + } + + @Test + void transferAmount_recipientNotFound_throwsException() { + when(accountRepository.findByUsername("nonexistent")).thenReturn(Optional.empty()); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> accountService.transferAmount(account, "nonexistent", new BigDecimal("100.00"))); + assertEquals("Recipient account not found", ex.getMessage()); + } + + @Test + void findAccountByUsername_notFound_throwsException() { + when(accountRepository.findByUsername("unknown")).thenReturn(Optional.empty()); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> accountService.findAccountByUsername("unknown")); + assertEquals("Account not found", ex.getMessage()); + } + + @Test + void registerAccount_duplicateUsername_throwsException() { + when(accountRepository.findByUsername("testuser")).thenReturn(Optional.of(account)); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> accountService.registerAccount("testuser", "password")); + assertEquals("Username already exists", ex.getMessage()); + } + + @Test + void deposit_zeroAmount_noValidation() { + accountService.deposit(account, BigDecimal.ZERO); + + assertEquals(new BigDecimal("1000.00"), account.getBalance()); + verify(accountRepository).save(account); + verify(transactionRepository).save(any(Transaction.class)); + } + + @Test + void deposit_negativeAmount_noValidation() { + accountService.deposit(account, new BigDecimal("-100.00")); + + assertEquals(new BigDecimal("900.00"), account.getBalance()); + verify(accountRepository).save(account); + verify(transactionRepository).save(any(Transaction.class)); + } +} diff --git a/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java b/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java new file mode 100644 index 00000000..810f3c96 --- /dev/null +++ b/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java @@ -0,0 +1,75 @@ +package com.example.bankapp.service; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +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 AccountServiceTransactionSafetyTest { + + @Mock + private AccountRepository accountRepository; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private AccountService accountService; + + private Account sender; + private Account recipient; + + @BeforeEach + void setUp() { + sender = new Account(); + sender.setId(1L); + sender.setUsername("sender"); + sender.setBalance(new BigDecimal("1000.00")); + + recipient = new Account(); + recipient.setId(2L); + recipient.setUsername("recipient"); + recipient.setBalance(new BigDecimal("500.00")); + } + + @Test + void transferAmount_notAtomic_senderDebitedButRecipientNotCredited() { + when(accountRepository.findByUsername("recipient")).thenReturn(Optional.of(recipient)); + + // First save (sender) succeeds, second save (recipient) throws + when(accountRepository.save(any(Account.class))) + .thenAnswer(invocation -> invocation.getArgument(0)) // sender save succeeds + .thenThrow(new RuntimeException("Database error")); // recipient save fails + + assertThrows(RuntimeException.class, + () -> accountService.transferAmount(sender, "recipient", new BigDecimal("200.00"))); + + // The sender's balance was deducted (in-memory) + assertEquals(new BigDecimal("800.00"), sender.getBalance()); + // The recipient never got credited because the save failed + // but the in-memory object was already mutated + assertEquals(new BigDecimal("700.00"), recipient.getBalance()); + + // The sender's save was called (balance deducted and persisted) + // but the recipient's save threw an exception — no rollback occurred + // This demonstrates the atomicity problem: sender loses money, recipient doesn't receive it + verify(accountRepository, times(2)).save(any(Account.class)); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 550f3d4d..8ac89735 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -4,3 +4,4 @@ spring.datasource.username=sa spring.datasource.password= spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect From 7c268eb9504e1a55fdf785b14e50a24ce08c851e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:25:59 +0000 Subject: [PATCH 3/5] refactor: add custom exceptions, @Transactional, and input validation Co-Authored-By: Joao Esteves --- .../bankapp/controller/BankController.java | 17 ++++--- .../exception/AccountNotFoundException.java | 8 ++++ .../bankapp/exception/BankAppException.java | 12 +++++ .../exception/DuplicateUsernameException.java | 8 ++++ .../exception/InsufficientFundsException.java | 8 ++++ .../exception/InvalidAmountException.java | 8 ++++ .../bankapp/service/AccountService.java | 44 ++++++++++++------- .../bankapp/service/AccountServiceTest.java | 40 ++++++++++------- .../AccountServiceTransactionSafetyTest.java | 35 ++++++++++----- 9 files changed, 132 insertions(+), 48 deletions(-) create mode 100644 src/main/java/com/example/bankapp/exception/AccountNotFoundException.java create mode 100644 src/main/java/com/example/bankapp/exception/BankAppException.java create mode 100644 src/main/java/com/example/bankapp/exception/DuplicateUsernameException.java create mode 100644 src/main/java/com/example/bankapp/exception/InsufficientFundsException.java create mode 100644 src/main/java/com/example/bankapp/exception/InvalidAmountException.java diff --git a/src/main/java/com/example/bankapp/controller/BankController.java b/src/main/java/com/example/bankapp/controller/BankController.java index 19fcded7..f7d93a7a 100644 --- a/src/main/java/com/example/bankapp/controller/BankController.java +++ b/src/main/java/com/example/bankapp/controller/BankController.java @@ -1,5 +1,6 @@ package com.example.bankapp.controller; +import com.example.bankapp.exception.BankAppException; import com.example.bankapp.model.Account; import com.example.bankapp.service.AccountService; import org.springframework.beans.factory.annotation.Autowired; @@ -36,7 +37,7 @@ public String registerAccount(@RequestParam String username, @RequestParam Strin try { accountService.registerAccount(username, password); return "redirect:/login"; - } catch (RuntimeException e) { + } catch (BankAppException e) { model.addAttribute("error", e.getMessage()); return "register"; } @@ -48,10 +49,16 @@ public String login() { } @PostMapping("/deposit") - public String deposit(@RequestParam BigDecimal amount) { + public String deposit(@RequestParam BigDecimal amount, Model model) { String username = SecurityContextHolder.getContext().getAuthentication().getName(); Account account = accountService.findAccountByUsername(username); - accountService.deposit(account, amount); + try { + accountService.deposit(account, amount); + } catch (BankAppException e) { + model.addAttribute("error", e.getMessage()); + model.addAttribute("account", account); + return "dashboard"; + } return "redirect:/dashboard"; } @@ -62,7 +69,7 @@ public String withdraw(@RequestParam BigDecimal amount, Model model) { try { accountService.withdraw(account, amount); - } catch (RuntimeException e) { + } catch (BankAppException e) { model.addAttribute("error", e.getMessage()); model.addAttribute("account", account); return "dashboard"; @@ -86,7 +93,7 @@ public String transferAmount(@RequestParam String toUsername, @RequestParam BigD try { accountService.transferAmount(fromAccount, toUsername, amount); - } catch (RuntimeException e) { + } catch (BankAppException e) { model.addAttribute("error", e.getMessage()); model.addAttribute("account", fromAccount); return "dashboard"; diff --git a/src/main/java/com/example/bankapp/exception/AccountNotFoundException.java b/src/main/java/com/example/bankapp/exception/AccountNotFoundException.java new file mode 100644 index 00000000..efafa2f6 --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/AccountNotFoundException.java @@ -0,0 +1,8 @@ +package com.example.bankapp.exception; + +public class AccountNotFoundException extends BankAppException { + + public AccountNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/bankapp/exception/BankAppException.java b/src/main/java/com/example/bankapp/exception/BankAppException.java new file mode 100644 index 00000000..ce660013 --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/BankAppException.java @@ -0,0 +1,12 @@ +package com.example.bankapp.exception; + +public abstract class BankAppException extends RuntimeException { + + protected BankAppException(String message) { + super(message); + } + + protected BankAppException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/example/bankapp/exception/DuplicateUsernameException.java b/src/main/java/com/example/bankapp/exception/DuplicateUsernameException.java new file mode 100644 index 00000000..dd85b346 --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/DuplicateUsernameException.java @@ -0,0 +1,8 @@ +package com.example.bankapp.exception; + +public class DuplicateUsernameException extends BankAppException { + + public DuplicateUsernameException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/bankapp/exception/InsufficientFundsException.java b/src/main/java/com/example/bankapp/exception/InsufficientFundsException.java new file mode 100644 index 00000000..bf017312 --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/InsufficientFundsException.java @@ -0,0 +1,8 @@ +package com.example.bankapp.exception; + +public class InsufficientFundsException extends BankAppException { + + public InsufficientFundsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/bankapp/exception/InvalidAmountException.java b/src/main/java/com/example/bankapp/exception/InvalidAmountException.java new file mode 100644 index 00000000..14a4fc40 --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/InvalidAmountException.java @@ -0,0 +1,8 @@ +package com.example.bankapp.exception; + +public class InvalidAmountException extends BankAppException { + + public InvalidAmountException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/bankapp/service/AccountService.java b/src/main/java/com/example/bankapp/service/AccountService.java index 5d7d90ec..53d92b7e 100644 --- a/src/main/java/com/example/bankapp/service/AccountService.java +++ b/src/main/java/com/example/bankapp/service/AccountService.java @@ -1,5 +1,9 @@ package com.example.bankapp.service; +import com.example.bankapp.exception.AccountNotFoundException; +import com.example.bankapp.exception.DuplicateUsernameException; +import com.example.bankapp.exception.InsufficientFundsException; +import com.example.bankapp.exception.InvalidAmountException; import com.example.bankapp.model.Account; import com.example.bankapp.model.Transaction; import com.example.bankapp.repository.AccountRepository; @@ -12,6 +16,7 @@ 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; @@ -32,23 +37,28 @@ public class AccountService implements UserDetailsService { private TransactionRepository transactionRepository; public Account findAccountByUsername(String username) { - return accountRepository.findByUsername(username).orElseThrow(() -> new RuntimeException("Account not found")); + return accountRepository.findByUsername(username) + .orElseThrow(() -> new AccountNotFoundException("Account not found")); } + @Transactional public Account registerAccount(String username, String password) { if (accountRepository.findByUsername(username).isPresent()) { - throw new RuntimeException("Username already exists"); + throw new DuplicateUsernameException("Username already exists"); } Account account = new Account(); account.setUsername(username); - account.setPassword(passwordEncoder.encode(password)); // Encrypt password - account.setBalance(BigDecimal.ZERO); // Initial balance set to 0 + account.setPassword(passwordEncoder.encode(password)); + account.setBalance(BigDecimal.ZERO); return accountRepository.save(account); } - + @Transactional public void deposit(Account account, BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new InvalidAmountException("Amount must be positive"); + } account.setBalance(account.getBalance().add(amount)); accountRepository.save(account); @@ -61,9 +71,13 @@ public void deposit(Account account, BigDecimal amount) { transactionRepository.save(transaction); } + @Transactional public void withdraw(Account account, BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new InvalidAmountException("Amount must be positive"); + } if (account.getBalance().compareTo(amount) < 0) { - throw new RuntimeException("Insufficient funds"); + throw new InsufficientFundsException("Insufficient funds"); } account.setBalance(account.getBalance().subtract(amount)); accountRepository.save(account); @@ -84,10 +98,8 @@ public List getTransactionHistory(Account account) { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - Account account = findAccountByUsername(username); - if (account == null) { - throw new UsernameNotFoundException("Username or Password not found"); - } + Account account = accountRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("Username or Password not found")); return new Account( account.getUsername(), account.getPassword(), @@ -100,23 +112,24 @@ public Collection authorities() { return Arrays.asList(new SimpleGrantedAuthority("USER")); } + @Transactional public void transferAmount(Account fromAccount, String toUsername, BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new InvalidAmountException("Amount must be positive"); + } if (fromAccount.getBalance().compareTo(amount) < 0) { - throw new RuntimeException("Insufficient funds"); + throw new InsufficientFundsException("Insufficient funds"); } Account toAccount = accountRepository.findByUsername(toUsername) - .orElseThrow(() -> new RuntimeException("Recipient account not found")); + .orElseThrow(() -> new AccountNotFoundException("Recipient account not found")); - // Deduct from sender's account fromAccount.setBalance(fromAccount.getBalance().subtract(amount)); accountRepository.save(fromAccount); - // Add to recipient's account 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(), @@ -133,5 +146,4 @@ public void transferAmount(Account fromAccount, String toUsername, BigDecimal am ); transactionRepository.save(creditTransaction); } - } diff --git a/src/test/java/com/example/bankapp/service/AccountServiceTest.java b/src/test/java/com/example/bankapp/service/AccountServiceTest.java index ef724611..c44ca016 100644 --- a/src/test/java/com/example/bankapp/service/AccountServiceTest.java +++ b/src/test/java/com/example/bankapp/service/AccountServiceTest.java @@ -1,5 +1,9 @@ package com.example.bankapp.service; +import com.example.bankapp.exception.AccountNotFoundException; +import com.example.bankapp.exception.DuplicateUsernameException; +import com.example.bankapp.exception.InsufficientFundsException; +import com.example.bankapp.exception.InvalidAmountException; import com.example.bankapp.model.Account; import com.example.bankapp.model.Transaction; import com.example.bankapp.repository.AccountRepository; @@ -71,7 +75,7 @@ void withdraw_decreasesBalanceAndSavesTransaction() { void withdraw_insufficientFunds_throwsException() { BigDecimal amount = new BigDecimal("2000.00"); - RuntimeException ex = assertThrows(RuntimeException.class, + InsufficientFundsException ex = assertThrows(InsufficientFundsException.class, () -> accountService.withdraw(account, amount)); assertEquals("Insufficient funds", ex.getMessage()); } @@ -106,7 +110,7 @@ void transferAmount_happyPath() { @Test void transferAmount_insufficientFunds_throwsException() { - RuntimeException ex = assertThrows(RuntimeException.class, + InsufficientFundsException ex = assertThrows(InsufficientFundsException.class, () -> accountService.transferAmount(account, "recipient", new BigDecimal("5000.00"))); assertEquals("Insufficient funds", ex.getMessage()); } @@ -115,7 +119,7 @@ void transferAmount_insufficientFunds_throwsException() { void transferAmount_recipientNotFound_throwsException() { when(accountRepository.findByUsername("nonexistent")).thenReturn(Optional.empty()); - RuntimeException ex = assertThrows(RuntimeException.class, + AccountNotFoundException ex = assertThrows(AccountNotFoundException.class, () -> accountService.transferAmount(account, "nonexistent", new BigDecimal("100.00"))); assertEquals("Recipient account not found", ex.getMessage()); } @@ -124,7 +128,7 @@ void transferAmount_recipientNotFound_throwsException() { void findAccountByUsername_notFound_throwsException() { when(accountRepository.findByUsername("unknown")).thenReturn(Optional.empty()); - RuntimeException ex = assertThrows(RuntimeException.class, + AccountNotFoundException ex = assertThrows(AccountNotFoundException.class, () -> accountService.findAccountByUsername("unknown")); assertEquals("Account not found", ex.getMessage()); } @@ -133,26 +137,32 @@ void findAccountByUsername_notFound_throwsException() { void registerAccount_duplicateUsername_throwsException() { when(accountRepository.findByUsername("testuser")).thenReturn(Optional.of(account)); - RuntimeException ex = assertThrows(RuntimeException.class, + DuplicateUsernameException ex = assertThrows(DuplicateUsernameException.class, () -> accountService.registerAccount("testuser", "password")); assertEquals("Username already exists", ex.getMessage()); } @Test - void deposit_zeroAmount_noValidation() { - accountService.deposit(account, BigDecimal.ZERO); + void deposit_zeroAmount_throwsInvalidAmountException() { + assertThrows(InvalidAmountException.class, + () -> accountService.deposit(account, BigDecimal.ZERO)); + } - assertEquals(new BigDecimal("1000.00"), account.getBalance()); - verify(accountRepository).save(account); - verify(transactionRepository).save(any(Transaction.class)); + @Test + void deposit_negativeAmount_throwsInvalidAmountException() { + assertThrows(InvalidAmountException.class, + () -> accountService.deposit(account, new BigDecimal("-100.00"))); } @Test - void deposit_negativeAmount_noValidation() { - accountService.deposit(account, new BigDecimal("-100.00")); + void withdraw_negativeAmount_throwsInvalidAmountException() { + assertThrows(InvalidAmountException.class, + () -> accountService.withdraw(account, new BigDecimal("-50.00"))); + } - assertEquals(new BigDecimal("900.00"), account.getBalance()); - verify(accountRepository).save(account); - verify(transactionRepository).save(any(Transaction.class)); + @Test + void transfer_negativeAmount_throwsInvalidAmountException() { + assertThrows(InvalidAmountException.class, + () -> accountService.transferAmount(account, "someone", new BigDecimal("-200.00"))); } } diff --git a/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java b/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java index 810f3c96..8bb089df 100644 --- a/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java +++ b/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java @@ -50,26 +50,37 @@ void setUp() { } @Test - void transferAmount_notAtomic_senderDebitedButRecipientNotCredited() { + void transferAmount_withTransactional_rollsBackOnFailure() { when(accountRepository.findByUsername("recipient")).thenReturn(Optional.of(recipient)); // First save (sender) succeeds, second save (recipient) throws when(accountRepository.save(any(Account.class))) - .thenAnswer(invocation -> invocation.getArgument(0)) // sender save succeeds - .thenThrow(new RuntimeException("Database error")); // recipient save fails + .thenAnswer(invocation -> invocation.getArgument(0)) + .thenThrow(new RuntimeException("Database error")); assertThrows(RuntimeException.class, () -> accountService.transferAmount(sender, "recipient", new BigDecimal("200.00"))); - // The sender's balance was deducted (in-memory) - assertEquals(new BigDecimal("800.00"), sender.getBalance()); - // The recipient never got credited because the save failed - // but the in-memory object was already mutated - assertEquals(new BigDecimal("700.00"), recipient.getBalance()); - - // The sender's save was called (balance deducted and persisted) - // but the recipient's save threw an exception — no rollback occurred - // This demonstrates the atomicity problem: sender loses money, recipient doesn't receive it + // In unit tests (no Spring context), @Transactional doesn't provide actual rollback. + // The in-memory objects are still mutated, but with @Transactional in production, + // the database changes would be rolled back by the transaction manager. + // This test verifies the exception propagates, which triggers the rollback. verify(accountRepository, times(2)).save(any(Account.class)); } + + @Test + void transferAmount_exceptionPropagates_enablingTransactionalRollback() { + when(accountRepository.findByUsername("recipient")).thenReturn(Optional.of(recipient)); + + when(accountRepository.save(any(Account.class))) + .thenAnswer(invocation -> invocation.getArgument(0)) + .thenThrow(new RuntimeException("Database error")); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> accountService.transferAmount(sender, "recipient", new BigDecimal("200.00"))); + + assertEquals("Database error", ex.getMessage()); + // The exception is not caught internally, so @Transactional will trigger rollback + // in a real Spring context, undoing the sender's balance deduction + } } From aedb67d81620c3a7d62838bf254d5993ce5c497e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:26:57 +0000 Subject: [PATCH 4/5] feat: add REST API layer with DTOs, global error handling, and API tests Co-Authored-By: Joao Esteves --- .../bankapp/config/SecurityConfig.java | 21 ++- .../bankapp/controller/BankApiController.java | 67 +++++++ .../example/bankapp/dto/AccountResponse.java | 49 +++++ .../com/example/bankapp/dto/ApiResponse.java | 49 +++++ .../example/bankapp/dto/DepositRequest.java | 23 +++ .../bankapp/dto/TransactionResponse.java | 65 +++++++ .../example/bankapp/dto/TransferRequest.java | 33 ++++ .../example/bankapp/dto/WithdrawRequest.java | 23 +++ .../exception/GlobalExceptionHandler.java | 41 ++++ .../controller/BankApiControllerTest.java | 176 ++++++++++++++++++ 10 files changed, 546 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/bankapp/controller/BankApiController.java create mode 100644 src/main/java/com/example/bankapp/dto/AccountResponse.java create mode 100644 src/main/java/com/example/bankapp/dto/ApiResponse.java create mode 100644 src/main/java/com/example/bankapp/dto/DepositRequest.java create mode 100644 src/main/java/com/example/bankapp/dto/TransactionResponse.java create mode 100644 src/main/java/com/example/bankapp/dto/TransferRequest.java create mode 100644 src/main/java/com/example/bankapp/dto/WithdrawRequest.java create mode 100644 src/main/java/com/example/bankapp/exception/GlobalExceptionHandler.java create mode 100644 src/test/java/com/example/bankapp/controller/BankApiControllerTest.java diff --git a/src/main/java/com/example/bankapp/config/SecurityConfig.java b/src/main/java/com/example/bankapp/config/SecurityConfig.java index 4dbd1572..0dda796d 100644 --- a/src/main/java/com/example/bankapp/config/SecurityConfig.java +++ b/src/main/java/com/example/bankapp/config/SecurityConfig.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -25,6 +26,24 @@ public static PasswordEncoder passwordEncoder() { } @Bean + @Order(1) + public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/**") + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(authz -> authz + .anyRequest().authenticated() + ) + .httpBasic(basic -> {}) + .headers(headers -> headers + .frameOptions(frameOptions -> frameOptions.sameOrigin()) + ); + + return http.build(); + } + + @Bean + @Order(2) public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) @@ -57,4 +76,4 @@ public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception auth.userDetailsService(accountService).passwordEncoder(passwordEncoder()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/example/bankapp/controller/BankApiController.java b/src/main/java/com/example/bankapp/controller/BankApiController.java new file mode 100644 index 00000000..e7817ff0 --- /dev/null +++ b/src/main/java/com/example/bankapp/controller/BankApiController.java @@ -0,0 +1,67 @@ +package com.example.bankapp.controller; + +import com.example.bankapp.dto.*; +import com.example.bankapp.model.Account; +import com.example.bankapp.model.Transaction; +import com.example.bankapp.service.AccountService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1") +public class BankApiController { + + private final AccountService accountService; + + public BankApiController(AccountService accountService) { + this.accountService = accountService; + } + + @PostMapping("/deposit") + public ResponseEntity> deposit(@RequestBody DepositRequest request) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + Account account = accountService.findAccountByUsername(username); + accountService.deposit(account, request.getAmount()); + Account updated = accountService.findAccountByUsername(username); + return ResponseEntity.ok(ApiResponse.ok("Deposit successful", AccountResponse.from(updated))); + } + + @PostMapping("/withdraw") + public ResponseEntity> withdraw(@RequestBody WithdrawRequest request) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + Account account = accountService.findAccountByUsername(username); + accountService.withdraw(account, request.getAmount()); + Account updated = accountService.findAccountByUsername(username); + return ResponseEntity.ok(ApiResponse.ok("Withdrawal successful", AccountResponse.from(updated))); + } + + @PostMapping("/transfer") + public ResponseEntity> transfer(@RequestBody TransferRequest request) { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + Account fromAccount = accountService.findAccountByUsername(username); + accountService.transferAmount(fromAccount, request.getToUsername(), request.getAmount()); + Account updated = accountService.findAccountByUsername(username); + return ResponseEntity.ok(ApiResponse.ok("Transfer successful", AccountResponse.from(updated))); + } + + @GetMapping("/balance") + public ResponseEntity> getBalance() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + Account account = accountService.findAccountByUsername(username); + return ResponseEntity.ok(ApiResponse.ok("Balance retrieved", AccountResponse.from(account))); + } + + @GetMapping("/transactions") + public ResponseEntity>> getTransactions() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + Account account = accountService.findAccountByUsername(username); + List transactions = accountService.getTransactionHistory(account); + List response = transactions.stream() + .map(TransactionResponse::from) + .toList(); + return ResponseEntity.ok(ApiResponse.ok("Transactions retrieved", response)); + } +} diff --git a/src/main/java/com/example/bankapp/dto/AccountResponse.java b/src/main/java/com/example/bankapp/dto/AccountResponse.java new file mode 100644 index 00000000..8afb4e4e --- /dev/null +++ b/src/main/java/com/example/bankapp/dto/AccountResponse.java @@ -0,0 +1,49 @@ +package com.example.bankapp.dto; + +import com.example.bankapp.model.Account; + +import java.math.BigDecimal; + +public class AccountResponse { + + private Long id; + private String username; + private BigDecimal balance; + + public AccountResponse() { + } + + public AccountResponse(Long id, String username, BigDecimal balance) { + this.id = id; + this.username = username; + this.balance = balance; + } + + public static AccountResponse from(Account account) { + return new AccountResponse(account.getId(), account.getUsername(), account.getBalance()); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public BigDecimal getBalance() { + return balance; + } + + public void setBalance(BigDecimal balance) { + this.balance = balance; + } +} diff --git a/src/main/java/com/example/bankapp/dto/ApiResponse.java b/src/main/java/com/example/bankapp/dto/ApiResponse.java new file mode 100644 index 00000000..fa237e21 --- /dev/null +++ b/src/main/java/com/example/bankapp/dto/ApiResponse.java @@ -0,0 +1,49 @@ +package com.example.bankapp.dto; + +public class ApiResponse { + + private boolean success; + private String message; + private T data; + + public ApiResponse() { + } + + public ApiResponse(boolean success, String message, T data) { + this.success = success; + this.message = message; + this.data = data; + } + + public static ApiResponse ok(String message, T data) { + return new ApiResponse<>(true, message, data); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(false, message, null); + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } +} diff --git a/src/main/java/com/example/bankapp/dto/DepositRequest.java b/src/main/java/com/example/bankapp/dto/DepositRequest.java new file mode 100644 index 00000000..3714671c --- /dev/null +++ b/src/main/java/com/example/bankapp/dto/DepositRequest.java @@ -0,0 +1,23 @@ +package com.example.bankapp.dto; + +import java.math.BigDecimal; + +public class DepositRequest { + + private BigDecimal amount; + + public DepositRequest() { + } + + public DepositRequest(BigDecimal amount) { + this.amount = amount; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } +} diff --git a/src/main/java/com/example/bankapp/dto/TransactionResponse.java b/src/main/java/com/example/bankapp/dto/TransactionResponse.java new file mode 100644 index 00000000..e7c4a38f --- /dev/null +++ b/src/main/java/com/example/bankapp/dto/TransactionResponse.java @@ -0,0 +1,65 @@ +package com.example.bankapp.dto; + +import com.example.bankapp.model.Transaction; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class TransactionResponse { + + private Long id; + private BigDecimal amount; + private String type; + private LocalDateTime timestamp; + + public TransactionResponse() { + } + + public TransactionResponse(Long id, BigDecimal amount, String type, LocalDateTime timestamp) { + this.id = id; + this.amount = amount; + this.type = type; + this.timestamp = timestamp; + } + + public static TransactionResponse from(Transaction transaction) { + return new TransactionResponse( + transaction.getId(), + transaction.getAmount(), + transaction.getType(), + transaction.getTimestamp() + ); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } +} diff --git a/src/main/java/com/example/bankapp/dto/TransferRequest.java b/src/main/java/com/example/bankapp/dto/TransferRequest.java new file mode 100644 index 00000000..5a1cbc7c --- /dev/null +++ b/src/main/java/com/example/bankapp/dto/TransferRequest.java @@ -0,0 +1,33 @@ +package com.example.bankapp.dto; + +import java.math.BigDecimal; + +public class TransferRequest { + + private String toUsername; + private BigDecimal amount; + + public TransferRequest() { + } + + public TransferRequest(String toUsername, BigDecimal amount) { + this.toUsername = toUsername; + this.amount = amount; + } + + public String getToUsername() { + return toUsername; + } + + public void setToUsername(String toUsername) { + this.toUsername = toUsername; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } +} diff --git a/src/main/java/com/example/bankapp/dto/WithdrawRequest.java b/src/main/java/com/example/bankapp/dto/WithdrawRequest.java new file mode 100644 index 00000000..5fc4390a --- /dev/null +++ b/src/main/java/com/example/bankapp/dto/WithdrawRequest.java @@ -0,0 +1,23 @@ +package com.example.bankapp.dto; + +import java.math.BigDecimal; + +public class WithdrawRequest { + + private BigDecimal amount; + + public WithdrawRequest() { + } + + public WithdrawRequest(BigDecimal amount) { + this.amount = amount; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } +} diff --git a/src/main/java/com/example/bankapp/exception/GlobalExceptionHandler.java b/src/main/java/com/example/bankapp/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..b08b2787 --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/GlobalExceptionHandler.java @@ -0,0 +1,41 @@ +package com.example.bankapp.exception; + +import com.example.bankapp.dto.ApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(InsufficientFundsException.class) + public ResponseEntity> handleInsufficientFunds(InsufficientFundsException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(ex.getMessage())); + } + + @ExceptionHandler(AccountNotFoundException.class) + public ResponseEntity> handleAccountNotFound(AccountNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(ex.getMessage())); + } + + @ExceptionHandler(DuplicateUsernameException.class) + public ResponseEntity> handleDuplicateUsername(DuplicateUsernameException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResponse.error(ex.getMessage())); + } + + @ExceptionHandler(InvalidAmountException.class) + public ResponseEntity> handleInvalidAmount(InvalidAmountException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(ex.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("An unexpected error occurred")); + } +} diff --git a/src/test/java/com/example/bankapp/controller/BankApiControllerTest.java b/src/test/java/com/example/bankapp/controller/BankApiControllerTest.java new file mode 100644 index 00000000..bad21896 --- /dev/null +++ b/src/test/java/com/example/bankapp/controller/BankApiControllerTest.java @@ -0,0 +1,176 @@ +package com.example.bankapp.controller; + +import com.example.bankapp.model.Account; +import com.example.bankapp.service.AccountService; +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.http.MediaType; +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.httpBasic; +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 BankApiControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private AccountService accountService; + + private void createUserWithBalance(String username, String password, BigDecimal balance) { + accountService.registerAccount(username, password); + if (balance.compareTo(BigDecimal.ZERO) > 0) { + Account account = accountService.findAccountByUsername(username); + accountService.deposit(account, balance); + } + } + + @Test + void deposit_validAmount_returns200WithUpdatedBalance() throws Exception { + createUserWithBalance("apiuser", "password123", BigDecimal.ZERO); + + mockMvc.perform(post("/api/v1/deposit") + .with(httpBasic("apiuser", "password123")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 500}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.balance").value(500)); + } + + @Test + void deposit_negativeAmount_returns400() throws Exception { + createUserWithBalance("apiuser2", "password123", BigDecimal.ZERO); + + mockMvc.perform(post("/api/v1/deposit") + .with(httpBasic("apiuser2", "password123")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": -100}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void withdraw_validAmount_returns200() throws Exception { + createUserWithBalance("apiuser3", "password123", new BigDecimal("1000")); + + mockMvc.perform(post("/api/v1/withdraw") + .with(httpBasic("apiuser3", "password123")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 300}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.balance").value(700)); + } + + @Test + void withdraw_insufficientFunds_returns400() throws Exception { + createUserWithBalance("apiuser4", "password123", new BigDecimal("100")); + + mockMvc.perform(post("/api/v1/withdraw") + .with(httpBasic("apiuser4", "password123")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 500}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void transfer_validRequest_returns200() throws Exception { + createUserWithBalance("sender1", "password123", new BigDecimal("1000")); + createUserWithBalance("receiver1", "password123", BigDecimal.ZERO); + + mockMvc.perform(post("/api/v1/transfer") + .with(httpBasic("sender1", "password123")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"toUsername\": \"receiver1\", \"amount\": 300}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.balance").value(700)); + } + + @Test + void transfer_recipientNotFound_returns404() throws Exception { + createUserWithBalance("sender2", "password123", new BigDecimal("1000")); + + mockMvc.perform(post("/api/v1/transfer") + .with(httpBasic("sender2", "password123")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"toUsername\": \"nonexistent\", \"amount\": 100}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void transfer_insufficientFunds_returns400() throws Exception { + createUserWithBalance("sender3", "password123", new BigDecimal("50")); + createUserWithBalance("receiver3", "password123", BigDecimal.ZERO); + + mockMvc.perform(post("/api/v1/transfer") + .with(httpBasic("sender3", "password123")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"toUsername\": \"receiver3\", \"amount\": 500}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void getBalance_authenticated_returns200() throws Exception { + createUserWithBalance("balanceuser", "password123", new BigDecimal("750")); + + mockMvc.perform(get("/api/v1/balance") + .with(httpBasic("balanceuser", "password123"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.balance").value(750)) + .andExpect(jsonPath("$.data.username").value("balanceuser")); + } + + @Test + void getTransactions_authenticated_returns200() throws Exception { + createUserWithBalance("txnuser", "password123", new BigDecimal("500")); + + mockMvc.perform(get("/api/v1/transactions") + .with(httpBasic("txnuser", "password123"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()); + } + + @Test + void allEndpoints_unauthenticated_returns401() throws Exception { + mockMvc.perform(post("/api/v1/deposit") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 100}")) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(post("/api/v1/withdraw") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 100}")) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(post("/api/v1/transfer") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"toUsername\": \"x\", \"amount\": 100}")) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(get("/api/v1/balance")) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(get("/api/v1/transactions")) + .andExpect(status().isUnauthorized()); + } +} From 52fa1bd6784afdf4b57f9fbd22d129f6235332af Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:27:57 +0000 Subject: [PATCH 5/5] refactor: migrate transaction logging to async event-driven pattern Co-Authored-By: Joao Esteves --- pom.xml | 5 ++ .../example/bankapp/BankappApplication.java | 2 + .../bankapp/event/TransactionEvent.java | 35 ++++++++ .../event/TransactionEventListener.java | 41 +++++++++ .../bankapp/service/AccountService.java | 49 +++++------ .../event/TransactionEventListenerTest.java | 87 +++++++++++++++++++ .../bankapp/service/AccountServiceTest.java | 18 ++-- .../AccountServiceTransactionSafetyTest.java | 13 ++- 8 files changed, 207 insertions(+), 43 deletions(-) create mode 100644 src/main/java/com/example/bankapp/event/TransactionEvent.java create mode 100644 src/main/java/com/example/bankapp/event/TransactionEventListener.java create mode 100644 src/test/java/com/example/bankapp/event/TransactionEventListenerTest.java diff --git a/pom.xml b/pom.xml index f6c9bdc2..ceecb375 100644 --- a/pom.xml +++ b/pom.xml @@ -72,6 +72,11 @@ h2 test + + org.awaitility + awaitility + test + diff --git a/src/main/java/com/example/bankapp/BankappApplication.java b/src/main/java/com/example/bankapp/BankappApplication.java index 84533986..707e07d1 100644 --- a/src/main/java/com/example/bankapp/BankappApplication.java +++ b/src/main/java/com/example/bankapp/BankappApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@EnableAsync public class BankappApplication { public static void main(String[] args) { 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..5318bd50 --- /dev/null +++ b/src/main/java/com/example/bankapp/event/TransactionEvent.java @@ -0,0 +1,35 @@ +package com.example.bankapp.event; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public class TransactionEvent { + + private final BigDecimal amount; + private final String type; + private final Long accountId; + private final LocalDateTime timestamp; + + public TransactionEvent(BigDecimal amount, String type, Long accountId, LocalDateTime timestamp) { + this.amount = amount; + this.type = type; + this.accountId = accountId; + this.timestamp = timestamp; + } + + public BigDecimal getAmount() { + return amount; + } + + public String getType() { + return type; + } + + public Long getAccountId() { + return accountId; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } +} 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..0899c5a4 --- /dev/null +++ b/src/main/java/com/example/bankapp/event/TransactionEventListener.java @@ -0,0 +1,41 @@ +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.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class TransactionEventListener { + + private final TransactionRepository transactionRepository; + private final AccountRepository accountRepository; + + public TransactionEventListener(TransactionRepository transactionRepository, + AccountRepository accountRepository) { + this.transactionRepository = transactionRepository; + this.accountRepository = accountRepository; + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleTransactionEvent(TransactionEvent event) { + Account account = accountRepository.findById(event.getAccountId()) + .orElse(null); + if (account == null) { + return; + } + + Transaction transaction = new Transaction( + event.getAmount(), + event.getType(), + event.getTimestamp(), + 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 53d92b7e..5634e201 100644 --- a/src/main/java/com/example/bankapp/service/AccountService.java +++ b/src/main/java/com/example/bankapp/service/AccountService.java @@ -1,5 +1,6 @@ package com.example.bankapp.service; +import com.example.bankapp.event.TransactionEvent; import com.example.bankapp.exception.AccountNotFoundException; import com.example.bankapp.exception.DuplicateUsernameException; import com.example.bankapp.exception.InsufficientFundsException; @@ -9,6 +10,7 @@ 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; @@ -36,6 +38,9 @@ public class AccountService implements UserDetailsService { @Autowired private TransactionRepository transactionRepository; + @Autowired + private ApplicationEventPublisher eventPublisher; + public Account findAccountByUsername(String username) { return accountRepository.findByUsername(username) .orElseThrow(() -> new AccountNotFoundException("Account not found")); @@ -62,13 +67,9 @@ 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(), LocalDateTime.now() + )); } @Transactional @@ -82,13 +83,9 @@ 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(), LocalDateTime.now() + )); } public List getTransactionHistory(Account account) { @@ -130,20 +127,14 @@ public void transferAmount(Account fromAccount, String toUsername, BigDecimal am toAccount.setBalance(toAccount.getBalance().add(amount)); accountRepository.save(toAccount); - 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); + eventPublisher.publishEvent(new TransactionEvent( + amount, "Transfer Out to " + toAccount.getUsername(), + fromAccount.getId(), LocalDateTime.now() + )); + + eventPublisher.publishEvent(new TransactionEvent( + amount, "Transfer In from " + fromAccount.getUsername(), + toAccount.getId(), LocalDateTime.now() + )); } } 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..7627c19d --- /dev/null +++ b/src/test/java/com/example/bankapp/event/TransactionEventListenerTest.java @@ -0,0 +1,87 @@ +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 com.example.bankapp.service.AccountService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +class TransactionEventListenerTest { + + @Autowired + private AccountService accountService; + + @Autowired + private AccountRepository accountRepository; + + @Autowired + private TransactionRepository transactionRepository; + + @Test + void deposit_publishesEventAndCreatesTransaction() { + Account account = accountService.registerAccount("eventuser1", "password123"); + + accountService.deposit(account, new BigDecimal("500")); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + List txns = transactionRepository.findByAccountId(account.getId()); + assertFalse(txns.isEmpty()); + assertEquals("Deposit", txns.get(0).getType()); + assertEquals(0, new BigDecimal("500").compareTo(txns.get(0).getAmount())); + }); + } + + @Test + void withdraw_publishesEventAndCreatesTransaction() { + Account account = accountService.registerAccount("eventuser2", "password123"); + accountService.deposit(account, new BigDecimal("1000")); + + Account updated = accountService.findAccountByUsername("eventuser2"); + accountService.withdraw(updated, new BigDecimal("300")); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + List txns = transactionRepository.findByAccountId(account.getId()); + boolean hasWithdrawal = txns.stream() + .anyMatch(t -> "Withdrawal".equals(t.getType())); + assertTrue(hasWithdrawal); + }); + } + + @Test + void transfer_publishesEventsAndCreatesTransactions() { + Account sender = accountService.registerAccount("eventsender", "password123"); + accountService.deposit(sender, new BigDecimal("1000")); + + accountService.registerAccount("eventreceiver", "password123"); + + Account senderUpdated = accountService.findAccountByUsername("eventsender"); + accountService.transferAmount(senderUpdated, "eventreceiver", new BigDecimal("200")); + + Account receiver = accountService.findAccountByUsername("eventreceiver"); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + List senderTxns = transactionRepository.findByAccountId(sender.getId()); + boolean hasTransferOut = senderTxns.stream() + .anyMatch(t -> t.getType().startsWith("Transfer Out")); + assertTrue(hasTransferOut); + + List receiverTxns = transactionRepository.findByAccountId(receiver.getId()); + boolean hasTransferIn = receiverTxns.stream() + .anyMatch(t -> t.getType().startsWith("Transfer In")); + assertTrue(hasTransferIn); + }); + } +} diff --git a/src/test/java/com/example/bankapp/service/AccountServiceTest.java b/src/test/java/com/example/bankapp/service/AccountServiceTest.java index c44ca016..cc209557 100644 --- a/src/test/java/com/example/bankapp/service/AccountServiceTest.java +++ b/src/test/java/com/example/bankapp/service/AccountServiceTest.java @@ -1,11 +1,11 @@ package com.example.bankapp.service; +import com.example.bankapp.event.TransactionEvent; import com.example.bankapp.exception.AccountNotFoundException; import com.example.bankapp.exception.DuplicateUsernameException; import com.example.bankapp.exception.InsufficientFundsException; import com.example.bankapp.exception.InvalidAmountException; 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; @@ -14,6 +14,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import java.math.BigDecimal; @@ -35,6 +36,9 @@ class AccountServiceTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private AccountService accountService; @@ -50,25 +54,25 @@ void setUp() { } @Test - void deposit_increasesBalanceAndSavesTransaction() { + void deposit_increasesBalanceAndPublishesEvent() { BigDecimal amount = new BigDecimal("500.00"); accountService.deposit(account, amount); assertEquals(new BigDecimal("1500.00"), account.getBalance()); verify(accountRepository).save(account); - verify(transactionRepository).save(any(Transaction.class)); + verify(eventPublisher).publishEvent(any(TransactionEvent.class)); } @Test - void withdraw_decreasesBalanceAndSavesTransaction() { + void withdraw_decreasesBalanceAndPublishesEvent() { BigDecimal amount = new BigDecimal("300.00"); accountService.withdraw(account, amount); assertEquals(new BigDecimal("700.00"), account.getBalance()); verify(accountRepository).save(account); - verify(transactionRepository).save(any(Transaction.class)); + verify(eventPublisher).publishEvent(any(TransactionEvent.class)); } @Test @@ -88,7 +92,7 @@ void withdraw_exactBalance_succeeds() { assertEquals(BigDecimal.ZERO.setScale(2), account.getBalance().setScale(2)); verify(accountRepository).save(account); - verify(transactionRepository).save(any(Transaction.class)); + verify(eventPublisher).publishEvent(any(TransactionEvent.class)); } @Test @@ -105,7 +109,7 @@ void transferAmount_happyPath() { assertEquals(new BigDecimal("800.00"), account.getBalance()); assertEquals(new BigDecimal("700.00"), recipient.getBalance()); verify(accountRepository, times(2)).save(any(Account.class)); - verify(transactionRepository, times(2)).save(any(Transaction.class)); + verify(eventPublisher, times(2)).publishEvent(any(TransactionEvent.class)); } @Test diff --git a/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java b/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java index 8bb089df..2451cfc9 100644 --- a/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java +++ b/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java @@ -9,6 +9,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import java.math.BigDecimal; @@ -30,6 +31,9 @@ class AccountServiceTransactionSafetyTest { @Mock private PasswordEncoder passwordEncoder; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private AccountService accountService; @@ -53,7 +57,6 @@ void setUp() { void transferAmount_withTransactional_rollsBackOnFailure() { when(accountRepository.findByUsername("recipient")).thenReturn(Optional.of(recipient)); - // First save (sender) succeeds, second save (recipient) throws when(accountRepository.save(any(Account.class))) .thenAnswer(invocation -> invocation.getArgument(0)) .thenThrow(new RuntimeException("Database error")); @@ -61,10 +64,8 @@ void transferAmount_withTransactional_rollsBackOnFailure() { assertThrows(RuntimeException.class, () -> accountService.transferAmount(sender, "recipient", new BigDecimal("200.00"))); - // In unit tests (no Spring context), @Transactional doesn't provide actual rollback. - // The in-memory objects are still mutated, but with @Transactional in production, - // the database changes would be rolled back by the transaction manager. - // This test verifies the exception propagates, which triggers the rollback. + // With @Transactional, the database changes are rolled back when an exception occurs. + // In unit tests without Spring context, we verify the exception propagates. verify(accountRepository, times(2)).save(any(Account.class)); } @@ -80,7 +81,5 @@ void transferAmount_exceptionPropagates_enablingTransactionalRollback() { () -> accountService.transferAmount(sender, "recipient", new BigDecimal("200.00"))); assertEquals("Database error", ex.getMessage()); - // The exception is not caught internally, so @Transactional will trigger rollback - // in a real Spring context, undoing the sender's balance deduction } }