bilhej/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java
Joakim Mörling 3532e4d486
All checks were successful
CI / Lint, type check, unit tests, coverage (pull_request) Successful in 2m9s
CI / E2E browser tests (pull_request) Successful in 1m55s
Add account settings dropdown and verified email change flow.
Replace the header "Byt lösenord" link with an Inställningar menu for
changing email or password. Email changes are two-step: request with
password, confirmation link to the new address, then password again on
confirm so a wrong inbox cannot take over the account.

- Backend: EmailChangeService, V10 email_change_tokens, confirm API
- Frontend: ChangeEmailPage, ConfirmEmailChangePage, header dropdown
- E2E: account-settings round-trips, Mailpit verification, wrong-password guard
- Flyway: V9 restore for dev DBs, CI migration checks, V10 for email tokens

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 14:33:06 +02:00

237 lines
8.7 KiB
Java

package se.bilhalsning.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
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.crypto.password.PasswordEncoder;
import se.bilhalsning.entity.Subscription;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.EmailAlreadyExistsException;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.repository.UserRepository;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private UserService userService;
@Test
void shouldCreateUserWhenEmailIsNew() {
when(userRepository.existsByEmail("new@example.com")).thenReturn(false);
when(passwordEncoder.encode("password123")).thenReturn("hashed");
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
User result = userService.createUser("new@example.com", "password123");
assertNotNull(result);
assertEquals("new@example.com", result.getEmail());
assertEquals("hashed", result.getPasswordHash());
assertEquals(Subscription.NONE, result.getSubscription());
assertEquals("user", result.getRole());
verify(userRepository).save(any(User.class));
}
@Test
void shouldThrowWhenEmailAlreadyExists() {
when(userRepository.existsByEmail("taken@example.com")).thenReturn(true);
assertThrows(EmailAlreadyExistsException.class, () ->
userService.createUser("taken@example.com", "password123"));
verify(userRepository, never()).save(any(User.class));
}
@Test
void shouldNormalizeEmailToLowercase() {
when(userRepository.existsByEmail("user@example.com")).thenReturn(false);
when(passwordEncoder.encode(any())).thenReturn("hashed");
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
User result = userService.createUser("User@Example.COM", "password123");
assertEquals("user@example.com", result.getEmail());
verify(userRepository).existsByEmail("user@example.com");
}
@Test
void shouldTrimWhitespaceFromEmail() {
when(userRepository.existsByEmail("user@example.com")).thenReturn(false);
when(passwordEncoder.encode(any())).thenReturn("hashed");
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
User result = userService.createUser(" user@example.com ", "password123");
assertEquals("user@example.com", result.getEmail());
verify(userRepository).existsByEmail("user@example.com");
}
@Test
void shouldHashPasswordBeforeSave() {
when(userRepository.existsByEmail("test@example.com")).thenReturn(false);
when(passwordEncoder.encode("myPassword")).thenReturn("bcryptHash");
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
userService.createUser("test@example.com", "myPassword");
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(captor.capture());
assertEquals("bcryptHash", captor.getValue().getPasswordHash());
}
@Test
void shouldFindByEmailWhenExists() {
User user = new User();
user.setEmail("found@example.com");
when(userRepository.findByEmail("found@example.com")).thenReturn(Optional.of(user));
Optional<User> result = userService.findByEmail("found@example.com");
assertTrue(result.isPresent());
assertEquals("found@example.com", result.get().getEmail());
}
@Test
void shouldFindByEmailNormalizesInput() {
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.empty());
userService.findByEmail(" User@Example.COM ");
verify(userRepository).findByEmail("user@example.com");
}
@Test
void shouldReturnUserWhenCredentialsAreValid() {
User user = new User();
user.setEmail("user@example.com");
user.setPasswordHash("hashed");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("password123", "hashed")).thenReturn(true);
User result = userService.authenticate("user@example.com", "password123");
assertNotNull(result);
assertEquals("user@example.com", result.getEmail());
}
@Test
void shouldThrowWhenEmailNotFoundOnAuthenticate() {
when(userRepository.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
assertThrows(InvalidCredentialsException.class, () ->
userService.authenticate("unknown@example.com", "password123"));
}
@Test
void shouldThrowWhenPasswordDoesNotMatch() {
User user = new User();
user.setEmail("user@example.com");
user.setPasswordHash("hashed");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("wrongpassword", "hashed")).thenReturn(false);
assertThrows(InvalidCredentialsException.class, () ->
userService.authenticate("user@example.com", "wrongpassword"));
}
@Test
void shouldNormalizeEmailBeforeAuthenticating() {
User user = new User();
user.setEmail("user@example.com");
user.setPasswordHash("hashed");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("password123", "hashed")).thenReturn(true);
userService.authenticate(" User@Example.COM ", "password123");
verify(userRepository).findByEmail("user@example.com");
}
@Test
void shouldChangePasswordWhenCurrentPasswordMatches() {
User user = new User();
user.setEmail("admin@bilhej.se");
user.setPasswordHash("old-hash");
when(userRepository.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("test1234", "old-hash")).thenReturn(true);
when(passwordEncoder.encode("newpassword123")).thenReturn("new-hash");
when(userRepository.save(user)).thenReturn(user);
userService.changePassword("admin@bilhej.se", "test1234", "newpassword123");
assertEquals("new-hash", user.getPasswordHash());
verify(userRepository).save(user);
}
@Test
void shouldRejectChangePasswordWhenCurrentPasswordWrong() {
User user = new User();
user.setEmail("admin@bilhej.se");
user.setPasswordHash("old-hash");
when(userRepository.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("wrong", "old-hash")).thenReturn(false);
assertThrows(
InvalidCredentialsException.class,
() -> userService.changePassword("admin@bilhej.se", "wrong", "newpassword123"));
verify(userRepository, never()).save(any(User.class));
}
@Test
void shouldApplyEmailChangeWhenNewEmailAvailable() {
User user = new User();
user.setEmail("old@example.com");
user.setPasswordHash("hash");
user.setRole("user");
when(userRepository.existsByEmail("new@example.com")).thenReturn(false);
when(userRepository.save(user)).thenReturn(user);
User result = userService.applyEmailChange(user, "new@example.com");
assertEquals("new@example.com", result.getEmail());
verify(userRepository).save(user);
}
@Test
void shouldRejectApplyEmailChangeWhenNewEmailTaken() {
User user = new User();
user.setEmail("old@example.com");
user.setPasswordHash("hash");
when(userRepository.existsByEmail("taken@example.com")).thenReturn(true);
assertThrows(
EmailAlreadyExistsException.class,
() -> userService.applyEmailChange(user, "taken@example.com"));
verify(userRepository, never()).save(any(User.class));
}
}