diff --git a/pom.xml b/pom.xml
index fc5bfeac..ceecb375 100644
--- a/pom.xml
+++ b/pom.xml
@@ -67,6 +67,16 @@
spring-security-test
test
+
+ com.h2database
+ h2
+ test
+
+
+ org.awaitility
+ awaitility
+ test
+
@@ -80,8 +90,8 @@
maven-compiler-plugin
3.8.0
- 1.8
- 1.8
+ 17
+ 17
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/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/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/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/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/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/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/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..5634e201 100644
--- a/src/main/java/com/example/bankapp/service/AccountService.java
+++ b/src/main/java/com/example/bankapp/service/AccountService.java
@@ -1,10 +1,16 @@
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.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,6 +18,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;
@@ -31,50 +38,54 @@ 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"));
+ 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);
- Transaction transaction = new Transaction(
- amount,
- "Deposit",
- LocalDateTime.now(),
- account
- );
- transactionRepository.save(transaction);
+ eventPublisher.publishEvent(new TransactionEvent(
+ amount, "Deposit", account.getId(), LocalDateTime.now()
+ ));
}
+ @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);
- 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) {
@@ -84,10 +95,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,38 +109,32 @@ public Collection extends GrantedAuthority> 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(),
- 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/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/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());
+ }
+}
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/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
new file mode 100644
index 00000000..cc209557
--- /dev/null
+++ b/src/test/java/com/example/bankapp/service/AccountServiceTest.java
@@ -0,0 +1,172 @@
+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.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.context.ApplicationEventPublisher;
+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;
+
+ @Mock
+ private ApplicationEventPublisher eventPublisher;
+
+ @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_increasesBalanceAndPublishesEvent() {
+ BigDecimal amount = new BigDecimal("500.00");
+
+ accountService.deposit(account, amount);
+
+ assertEquals(new BigDecimal("1500.00"), account.getBalance());
+ verify(accountRepository).save(account);
+ verify(eventPublisher).publishEvent(any(TransactionEvent.class));
+ }
+
+ @Test
+ 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(eventPublisher).publishEvent(any(TransactionEvent.class));
+ }
+
+ @Test
+ void withdraw_insufficientFunds_throwsException() {
+ BigDecimal amount = new BigDecimal("2000.00");
+
+ InsufficientFundsException ex = assertThrows(InsufficientFundsException.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(eventPublisher).publishEvent(any(TransactionEvent.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(eventPublisher, times(2)).publishEvent(any(TransactionEvent.class));
+ }
+
+ @Test
+ void transferAmount_insufficientFunds_throwsException() {
+ InsufficientFundsException ex = assertThrows(InsufficientFundsException.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());
+
+ AccountNotFoundException ex = assertThrows(AccountNotFoundException.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());
+
+ AccountNotFoundException ex = assertThrows(AccountNotFoundException.class,
+ () -> accountService.findAccountByUsername("unknown"));
+ assertEquals("Account not found", ex.getMessage());
+ }
+
+ @Test
+ void registerAccount_duplicateUsername_throwsException() {
+ when(accountRepository.findByUsername("testuser")).thenReturn(Optional.of(account));
+
+ DuplicateUsernameException ex = assertThrows(DuplicateUsernameException.class,
+ () -> accountService.registerAccount("testuser", "password"));
+ assertEquals("Username already exists", ex.getMessage());
+ }
+
+ @Test
+ void deposit_zeroAmount_throwsInvalidAmountException() {
+ assertThrows(InvalidAmountException.class,
+ () -> accountService.deposit(account, BigDecimal.ZERO));
+ }
+
+ @Test
+ void deposit_negativeAmount_throwsInvalidAmountException() {
+ assertThrows(InvalidAmountException.class,
+ () -> accountService.deposit(account, new BigDecimal("-100.00")));
+ }
+
+ @Test
+ void withdraw_negativeAmount_throwsInvalidAmountException() {
+ assertThrows(InvalidAmountException.class,
+ () -> accountService.withdraw(account, new BigDecimal("-50.00")));
+ }
+
+ @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
new file mode 100644
index 00000000..2451cfc9
--- /dev/null
+++ b/src/test/java/com/example/bankapp/service/AccountServiceTransactionSafetyTest.java
@@ -0,0 +1,85 @@
+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.context.ApplicationEventPublisher;
+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;
+
+ @Mock
+ private ApplicationEventPublisher eventPublisher;
+
+ @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_withTransactional_rollsBackOnFailure() {
+ when(accountRepository.findByUsername("recipient")).thenReturn(Optional.of(recipient));
+
+ when(accountRepository.save(any(Account.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0))
+ .thenThrow(new RuntimeException("Database error"));
+
+ assertThrows(RuntimeException.class,
+ () -> accountService.transferAmount(sender, "recipient", new BigDecimal("200.00")));
+
+ // 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));
+ }
+
+ @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());
+ }
+}
diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties
new file mode 100644
index 00000000..8ac89735
--- /dev/null
+++ b/src/test/resources/application-test.properties
@@ -0,0 +1,7 @@
+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
+spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect