Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions src/test/java/com/example/bankapp/service/AccountServiceTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
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.ArgumentCaptor;
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.crypto.password.PasswordEncoder;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@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("alice");
account.setPassword("secret");
account.setBalance(new BigDecimal("100"));
}

// ----- deposit() -----

@Test
void deposit_shouldIncreaseBalanceByAmount() {
accountService.deposit(account, new BigDecimal("50"));

assertEquals(new BigDecimal("150"), account.getBalance());
verify(accountRepository).save(account);
verify(transactionRepository).save(any(Transaction.class));
}

@Test
void deposit_shouldCreateTransactionWithCorrectType() {
accountService.deposit(account, new BigDecimal("50"));

ArgumentCaptor<Transaction> captor = ArgumentCaptor.forClass(Transaction.class);
verify(transactionRepository).save(captor.capture());

Transaction saved = captor.getValue();
assertEquals("Deposit", saved.getType());
assertEquals(new BigDecimal("50"), saved.getAmount());
}

// ----- withdraw() -----

@Test
void withdraw_shouldDecreaseBalanceByAmount() {
accountService.withdraw(account, new BigDecimal("30"));

assertEquals(new BigDecimal("70"), account.getBalance());
verify(accountRepository).save(account);
verify(transactionRepository).save(any(Transaction.class));
}

@Test
void withdraw_shouldThrowRuntimeExceptionWhenInsufficientFunds() {
account.setBalance(new BigDecimal("10"));

RuntimeException ex = assertThrows(RuntimeException.class,
() -> accountService.withdraw(account, new BigDecimal("50")));

assertEquals("Insufficient funds", ex.getMessage());
verify(accountRepository, never()).save(any(Account.class));
verify(transactionRepository, never()).save(any(Transaction.class));
}

@Test
void withdraw_shouldCreateTransactionWithCorrectType() {
accountService.withdraw(account, new BigDecimal("30"));

ArgumentCaptor<Transaction> captor = ArgumentCaptor.forClass(Transaction.class);
verify(transactionRepository).save(captor.capture());

Transaction saved = captor.getValue();
assertEquals("Withdrawal", saved.getType());
assertEquals(new BigDecimal("30"), saved.getAmount());
}

// ----- transferAmount() -----

@Test
void transferAmount_shouldDeductFromSenderAndAddToRecipient() {
account.setBalance(new BigDecimal("200"));

Account recipient = new Account();
recipient.setId(2L);
recipient.setUsername("bob");
recipient.setBalance(new BigDecimal("0"));

when(accountRepository.findByUsername("bob")).thenReturn(Optional.of(recipient));

accountService.transferAmount(account, "bob", new BigDecimal("50"));

assertEquals(new BigDecimal("150"), account.getBalance());
assertEquals(new BigDecimal("50"), recipient.getBalance());
}

@Test
void transferAmount_shouldThrowWhenInsufficientFunds() {
account.setBalance(new BigDecimal("10"));

RuntimeException ex = assertThrows(RuntimeException.class,
() -> accountService.transferAmount(account, "bob", new BigDecimal("50")));

assertEquals("Insufficient funds", ex.getMessage());
verify(transactionRepository, never()).save(any(Transaction.class));
}

@Test
void transferAmount_shouldThrowWhenRecipientNotFound() {
account.setBalance(new BigDecimal("200"));

when(accountRepository.findByUsername("bob")).thenReturn(Optional.empty());

RuntimeException ex = assertThrows(RuntimeException.class,
() -> accountService.transferAmount(account, "bob", new BigDecimal("50")));

assertEquals("Recipient account not found", ex.getMessage());
}

@Test
void transferAmount_shouldCreateTwoTransactionRecords() {
account.setBalance(new BigDecimal("200"));

Account recipient = new Account();
recipient.setId(2L);
recipient.setUsername("bob");
recipient.setBalance(new BigDecimal("0"));

when(accountRepository.findByUsername("bob")).thenReturn(Optional.of(recipient));

accountService.transferAmount(account, "bob", new BigDecimal("50"));

verify(transactionRepository, times(2)).save(any(Transaction.class));
}

// ----- registerAccount() -----

@Test
void registerAccount_shouldCreateAccountWithZeroBalance() {
when(accountRepository.findByUsername("newuser")).thenReturn(Optional.empty());
when(passwordEncoder.encode("password")).thenReturn("encoded");
when(accountRepository.save(any(Account.class))).thenAnswer(invocation -> invocation.getArgument(0));

accountService.registerAccount("newuser", "password");

ArgumentCaptor<Account> captor = ArgumentCaptor.forClass(Account.class);
verify(accountRepository).save(captor.capture());

Account saved = captor.getValue();
assertEquals("newuser", saved.getUsername());
assertEquals("encoded", saved.getPassword());
assertEquals(BigDecimal.ZERO, saved.getBalance());
}

@Test
void registerAccount_shouldThrowWhenUsernameAlreadyExists() {
when(accountRepository.findByUsername("alice")).thenReturn(Optional.of(account));

RuntimeException ex = assertThrows(RuntimeException.class,
() -> accountService.registerAccount("alice", "password"));

assertEquals("Username already exists", ex.getMessage());
verify(accountRepository, never()).save(any(Account.class));
}

// ----- findAccountByUsername() -----

@Test
void findAccountByUsername_shouldReturnAccountWhenFound() {
when(accountRepository.findByUsername("alice")).thenReturn(Optional.of(account));

Account result = accountService.findAccountByUsername("alice");

assertSame(account, result);
}

@Test
void findAccountByUsername_shouldThrowWhenNotFound() {
when(accountRepository.findByUsername("ghost")).thenReturn(Optional.empty());

RuntimeException ex = assertThrows(RuntimeException.class,
() -> accountService.findAccountByUsername("ghost"));

assertEquals("Account not found", ex.getMessage());
}

// ----- loadUserByUsername() -----

@Test
void loadUserByUsername_shouldReturnUserDetailsWhenFound() {
when(accountRepository.findByUsername("alice")).thenReturn(Optional.of(account));

UserDetails userDetails = accountService.loadUserByUsername("alice");

assertEquals("alice", userDetails.getUsername());
assertEquals("secret", userDetails.getPassword());
}

@Test
void loadUserByUsername_shouldThrowWhenUsernameNotFound() {
when(accountRepository.findByUsername("ghost")).thenReturn(Optional.empty());

assertThrows(RuntimeException.class,
() -> accountService.loadUserByUsername("ghost"));
}
Comment on lines +237 to +242

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 loadUserByUsername test checks RuntimeException but method contract declares UsernameNotFoundException

The loadUserByUsername_shouldThrowWhenUsernameNotFound test asserts RuntimeException.class, which is technically correct for what the implementation does — findAccountByUsername at AccountService.java:87 calls orElseThrow(() -> new RuntimeException("Account not found")) at AccountService.java:35, throwing before the UsernameNotFoundException check on line 88-89 (which is dead code). However, the UserDetailsService contract specifies that UsernameNotFoundException should be thrown. This means the test is encoding a pre-existing bug in the service rather than testing the expected Spring Security contract. If the service is later fixed to throw UsernameNotFoundException, this test will break.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment on lines +237 to +242

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Test asserts RuntimeException instead of UsernameNotFoundException for loadUserByUsername

The loadUserByUsername_shouldThrowWhenUsernameNotFound test asserts RuntimeException.class is thrown. The Spring Security UserDetailsService contract expects a UsernameNotFoundException to be thrown when a user is not found. However, the actual service code at AccountService.java:87 delegates to findAccountByUsername() which throws a plain RuntimeException("Account not found") — so the test is accurately reflecting the current (arguably incorrect) service behavior. Since UsernameNotFoundException extends AuthenticationException extends RuntimeException, fixing the service to throw UsernameNotFoundException would not break this test either, making the test weaker than it could be. This is a pre-existing service issue, not a test bug.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — this is intentional. The test asserts RuntimeException because that's what the current service throws (findAccountByUsername -> RuntimeException("Account not found")), and the task spec for this test (#15) explicitly asks to "assert RuntimeException is thrown." Since UsernameNotFoundException extends RuntimeException, this assertion remains valid if the service is later hardened to throw the more specific Spring Security exception. Leaving the service behavior unchanged as it's out of scope for this test-only PR.


// ----- getTransactionHistory() -----

@Test
void getTransactionHistory_shouldReturnTransactionsForAccount() {
Transaction t1 = new Transaction(new BigDecimal("50"), "Deposit", LocalDateTime.now(), account);
Transaction t2 = new Transaction(new BigDecimal("30"), "Withdrawal", LocalDateTime.now(), account);
List<Transaction> transactions = List.of(t1, t2);

when(transactionRepository.findByAccountId(1L)).thenReturn(transactions);

List<Transaction> result = accountService.getTransactionHistory(account);

assertEquals(2, result.size());
assertEquals(transactions, result);
}
}