From 1c489e371f2434ff7133cc72885a25b0652a5503 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:16:01 +0000 Subject: [PATCH 1/2] Add test suite and CI pipeline - Add H2 in-memory database for test configuration - Add AccountService unit tests (13 tests) with Mockito - Add BankController integration tests (8 tests) with MockMvc - Add GitHub Actions CI workflow for DevOps and main branches - All 22 tests pass (including existing contextLoads) Co-Authored-By: Arjun Mishra --- .github/workflows/ci.yml | 31 +++ pom.xml | 5 + .../controller/BankControllerTest.java | 149 +++++++++++++ .../bankapp/service/AccountServiceTest.java | 206 ++++++++++++++++++ src/test/resources/application.properties | 6 + 5 files changed, 397 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 src/test/java/com/example/bankapp/controller/BankControllerTest.java create mode 100644 src/test/java/com/example/bankapp/service/AccountServiceTest.java create mode 100644 src/test/resources/application.properties diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e3188e4f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +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: ./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 From 841e2c01c8d687ddee35c909a40ceae0a4cd9a24 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:17:11 +0000 Subject: [PATCH 2/2] Fix CI: add chmod +x for mvnw Co-Authored-By: Arjun Mishra --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3188e4f..c076e094 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,9 @@ jobs: cache: maven - name: Build and Test - run: ./mvnw clean verify + run: | + chmod +x ./mvnw + ./mvnw clean verify - name: Upload test results if: always()