diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..c076e094
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,33 @@
+name: CI
+
+on:
+ push:
+ branches: [ DevOps, main ]
+ pull_request:
+ branches: [ DevOps, main ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: maven
+
+ - name: Build and Test
+ run: |
+ chmod +x ./mvnw
+ ./mvnw clean verify
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results
+ path: target/surefire-reports/
diff --git a/pom.xml b/pom.xml
index fc5bfeac..da3f7cc2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -67,6 +67,11 @@
spring-security-test
test
+
+ com.h2database
+ h2
+ test
+
diff --git a/src/test/java/com/example/bankapp/controller/BankControllerTest.java b/src/test/java/com/example/bankapp/controller/BankControllerTest.java
new file mode 100644
index 00000000..4a4b27d3
--- /dev/null
+++ b/src/test/java/com/example/bankapp/controller/BankControllerTest.java
@@ -0,0 +1,149 @@
+package com.example.bankapp.controller;
+
+import com.example.bankapp.model.Account;
+import com.example.bankapp.model.Transaction;
+import com.example.bankapp.service.AccountService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+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
+class BankControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockBean
+ private AccountService accountService;
+
+ private Account testAccount;
+
+ @BeforeEach
+ void setUp() {
+ testAccount = new Account();
+ testAccount.setId(1L);
+ testAccount.setUsername("testuser");
+ testAccount.setPassword("encodedPassword");
+ testAccount.setBalance(new BigDecimal("1000.00"));
+ testAccount.setTransactions(Collections.emptyList());
+ }
+
+ @Test
+ void register_GET_showsForm() throws Exception {
+ mockMvc.perform(get("/register"))
+ .andExpect(status().isOk())
+ .andExpect(view().name("register"));
+ }
+
+ @Test
+ void register_POST_success_redirectsToLogin() throws Exception {
+ when(accountService.registerAccount("newuser", "password123")).thenReturn(testAccount);
+
+ mockMvc.perform(post("/register")
+ .param("username", "newuser")
+ .param("password", "password123")
+ .with(csrf()))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/login"));
+ }
+
+ @Test
+ void register_POST_duplicateUsername_showsError() throws Exception {
+ when(accountService.registerAccount("testuser", "password123"))
+ .thenThrow(new RuntimeException("Username already exists"));
+
+ mockMvc.perform(post("/register")
+ .param("username", "testuser")
+ .param("password", "password123")
+ .with(csrf()))
+ .andExpect(status().isOk())
+ .andExpect(view().name("register"))
+ .andExpect(model().attributeExists("error"));
+ }
+
+ @Test
+ void dashboard_requiresAuthentication() throws Exception {
+ mockMvc.perform(get("/dashboard"))
+ .andExpect(status().is3xxRedirection());
+ }
+
+ @Test
+ @WithMockUser(username = "testuser")
+ void deposit_POST_updatesBalance() throws Exception {
+ when(accountService.findAccountByUsername("testuser")).thenReturn(testAccount);
+ doNothing().when(accountService).deposit(any(Account.class), any(BigDecimal.class));
+
+ mockMvc.perform(post("/deposit")
+ .param("amount", "500.00")
+ .with(csrf()))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/dashboard"));
+
+ verify(accountService).deposit(any(Account.class), eq(new BigDecimal("500.00")));
+ }
+
+ @Test
+ @WithMockUser(username = "testuser")
+ void withdraw_POST_updatesBalance() throws Exception {
+ when(accountService.findAccountByUsername("testuser")).thenReturn(testAccount);
+ doNothing().when(accountService).withdraw(any(Account.class), any(BigDecimal.class));
+
+ mockMvc.perform(post("/withdraw")
+ .param("amount", "200.00")
+ .with(csrf()))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/dashboard"));
+
+ verify(accountService).withdraw(any(Account.class), eq(new BigDecimal("200.00")));
+ }
+
+ @Test
+ @WithMockUser(username = "testuser")
+ void withdraw_POST_insufficientFunds_showsError() throws Exception {
+ when(accountService.findAccountByUsername("testuser")).thenReturn(testAccount);
+ doThrow(new RuntimeException("Insufficient funds"))
+ .when(accountService).withdraw(any(Account.class), any(BigDecimal.class));
+
+ mockMvc.perform(post("/withdraw")
+ .param("amount", "5000.00")
+ .with(csrf()))
+ .andExpect(status().isOk())
+ .andExpect(view().name("dashboard"))
+ .andExpect(model().attributeExists("error"));
+ }
+
+ @Test
+ @WithMockUser(username = "testuser")
+ void transactions_GET_showsHistory() throws Exception {
+ Transaction t1 = new Transaction(new BigDecimal("100.00"), "Deposit",
+ LocalDateTime.now(), testAccount);
+ when(accountService.findAccountByUsername("testuser")).thenReturn(testAccount);
+ when(accountService.getTransactionHistory(any(Account.class)))
+ .thenReturn(Arrays.asList(t1));
+
+ mockMvc.perform(get("/transactions"))
+ .andExpect(status().isOk())
+ .andExpect(view().name("transactions"))
+ .andExpect(model().attributeExists("transactions"));
+ }
+}
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..c7b4d496
--- /dev/null
+++ b/src/test/java/com/example/bankapp/service/AccountServiceTest.java
@@ -0,0 +1,206 @@
+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.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class AccountServiceTest {
+
+ @Mock
+ private AccountRepository accountRepository;
+
+ @Mock
+ private TransactionRepository transactionRepository;
+
+ @Mock
+ private PasswordEncoder passwordEncoder;
+
+ @InjectMocks
+ private AccountService accountService;
+
+ private Account testAccount;
+
+ @BeforeEach
+ void setUp() {
+ testAccount = new Account();
+ testAccount.setId(1L);
+ testAccount.setUsername("testuser");
+ testAccount.setPassword("encodedPassword");
+ testAccount.setBalance(new BigDecimal("1000.00"));
+ }
+
+ @Test
+ void registerAccount_success() {
+ when(accountRepository.findByUsername("newuser")).thenReturn(Optional.empty());
+ when(passwordEncoder.encode("password123")).thenReturn("encodedPassword");
+ when(accountRepository.save(any(Account.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ Account result = accountService.registerAccount("newuser", "password123");
+
+ assertNotNull(result);
+ assertEquals("newuser", result.getUsername());
+ assertEquals("encodedPassword", result.getPassword());
+ assertEquals(BigDecimal.ZERO, result.getBalance());
+ verify(accountRepository).save(any(Account.class));
+ }
+
+ @Test
+ void registerAccount_duplicateUsername() {
+ when(accountRepository.findByUsername("testuser")).thenReturn(Optional.of(testAccount));
+
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> accountService.registerAccount("testuser", "password123"));
+
+ assertEquals("Username already exists", exception.getMessage());
+ verify(accountRepository, never()).save(any(Account.class));
+ }
+
+ @Test
+ void deposit_success() {
+ when(accountRepository.save(any(Account.class))).thenReturn(testAccount);
+ when(transactionRepository.save(any(Transaction.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ accountService.deposit(testAccount, new BigDecimal("500.00"));
+
+ assertEquals(new BigDecimal("1500.00"), testAccount.getBalance());
+ verify(accountRepository).save(testAccount);
+ verify(transactionRepository).save(any(Transaction.class));
+ }
+
+ @Test
+ void withdraw_success() {
+ when(accountRepository.save(any(Account.class))).thenReturn(testAccount);
+ when(transactionRepository.save(any(Transaction.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ accountService.withdraw(testAccount, new BigDecimal("300.00"));
+
+ assertEquals(new BigDecimal("700.00"), testAccount.getBalance());
+ verify(accountRepository).save(testAccount);
+ verify(transactionRepository).save(any(Transaction.class));
+ }
+
+ @Test
+ void withdraw_insufficientFunds() {
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> accountService.withdraw(testAccount, new BigDecimal("2000.00")));
+
+ assertEquals("Insufficient funds", exception.getMessage());
+ verify(accountRepository, never()).save(any(Account.class));
+ verify(transactionRepository, never()).save(any(Transaction.class));
+ }
+
+ @Test
+ void transferAmount_success() {
+ Account toAccount = new Account();
+ toAccount.setId(2L);
+ toAccount.setUsername("recipient");
+ toAccount.setPassword("encodedPassword");
+ toAccount.setBalance(new BigDecimal("500.00"));
+
+ when(accountRepository.findByUsername("recipient")).thenReturn(Optional.of(toAccount));
+ when(accountRepository.save(any(Account.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ when(transactionRepository.save(any(Transaction.class))).thenAnswer(invocation -> invocation.getArgument(0));
+
+ accountService.transferAmount(testAccount, "recipient", new BigDecimal("200.00"));
+
+ assertEquals(new BigDecimal("800.00"), testAccount.getBalance());
+ assertEquals(new BigDecimal("700.00"), toAccount.getBalance());
+ verify(accountRepository, times(2)).save(any(Account.class));
+ verify(transactionRepository, times(2)).save(any(Transaction.class));
+ }
+
+ @Test
+ void transferAmount_insufficientFunds() {
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> accountService.transferAmount(testAccount, "recipient", new BigDecimal("2000.00")));
+
+ assertEquals("Insufficient funds", exception.getMessage());
+ verify(accountRepository, never()).save(any(Account.class));
+ }
+
+ @Test
+ void transferAmount_recipientNotFound() {
+ when(accountRepository.findByUsername("unknown")).thenReturn(Optional.empty());
+
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> accountService.transferAmount(testAccount, "unknown", new BigDecimal("100.00")));
+
+ assertEquals("Recipient account not found", exception.getMessage());
+ }
+
+ @Test
+ void getTransactionHistory_returnsTransactions() {
+ Transaction t1 = new Transaction(new BigDecimal("100.00"), "Deposit",
+ java.time.LocalDateTime.now(), testAccount);
+ Transaction t2 = new Transaction(new BigDecimal("50.00"), "Withdrawal",
+ java.time.LocalDateTime.now(), testAccount);
+ List transactions = Arrays.asList(t1, t2);
+
+ when(transactionRepository.findByAccountId(1L)).thenReturn(transactions);
+
+ List result = accountService.getTransactionHistory(testAccount);
+
+ assertEquals(2, result.size());
+ verify(transactionRepository).findByAccountId(1L);
+ }
+
+ @Test
+ void loadUserByUsername_success() {
+ when(accountRepository.findByUsername("testuser")).thenReturn(Optional.of(testAccount));
+
+ UserDetails userDetails = accountService.loadUserByUsername("testuser");
+
+ assertNotNull(userDetails);
+ assertEquals("testuser", userDetails.getUsername());
+ }
+
+ @Test
+ void loadUserByUsername_notFound() {
+ when(accountRepository.findByUsername("unknown")).thenReturn(Optional.empty());
+
+ assertThrows(RuntimeException.class,
+ () -> accountService.loadUserByUsername("unknown"));
+ }
+
+ @Test
+ void findAccountByUsername_success() {
+ when(accountRepository.findByUsername("testuser")).thenReturn(Optional.of(testAccount));
+
+ Account result = accountService.findAccountByUsername("testuser");
+
+ assertNotNull(result);
+ assertEquals("testuser", result.getUsername());
+ }
+
+ @Test
+ void findAccountByUsername_notFound() {
+ when(accountRepository.findByUsername("unknown")).thenReturn(Optional.empty());
+
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> accountService.findAccountByUsername("unknown"));
+
+ assertEquals("Account not found", exception.getMessage());
+ }
+}
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
new file mode 100644
index 00000000..a3d58ec6
--- /dev/null
+++ b/src/test/resources/application.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.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect