feat: migrate from username to email-only authentication #272

Merged
marcel merged 6 commits from feat/issue-270-email-only-auth into main 2026-04-18 23:36:56 +02:00
51 changed files with 424 additions and 376 deletions

View File

@@ -31,8 +31,8 @@ import java.util.Set;
@DependsOn("flyway")
public class DataInitializer {
@Value("${app.admin.username:admin}")
private String adminUsername;
@Value("${app.admin.email:admin@familyarchive.local}")
private String adminEmail;
@Value("${app.admin.password:admin123}")
private String adminPassword;
@@ -43,26 +43,23 @@ public class DataInitializer {
@Bean
public CommandLineRunner initAdminUser(PasswordEncoder passwordEncoder) {
return args -> {
if (userRepository.findByUsername(adminUsername).isEmpty()) {
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminUsername);
if (userRepository.findByEmail(adminEmail).isEmpty()) {
log.info("Kein Admin-User '{}' gefunden. Erstelle Default-Admin...", adminEmail);
// 1. Admin Gruppe erstellen
UserGroup adminGroup = UserGroup.builder()
.name("Administrators")
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
.build();
groupRepository.save(adminGroup);
// 2. Admin User erstellen
AppUser admin = AppUser.builder()
.username(adminUsername)
.password(passwordEncoder.encode(adminPassword)) // Passwort verschlüsseln!
.email("admin@familyarchive.local")
.email(adminEmail)
.password(passwordEncoder.encode(adminPassword))
.groups(Set.of(adminGroup))
.build();
userRepository.save(admin);
log.info("Default Admin erstellt: User='{}'", adminUsername);
log.info("Default Admin erstellt: Email='{}'", adminEmail);
}
};
}
@@ -84,16 +81,13 @@ public class DataInitializer {
TagRepository tagRepo,
PasswordEncoder passwordEncoder) {
return args -> {
// Always reset the admin password to the configured value so a failed password-reset
// test from a previous run can never leave the account locked out.
userRepository.findByUsername(adminUsername).ifPresent(admin -> {
userRepository.findByEmail(adminEmail).ifPresent(admin -> {
admin.setPassword(passwordEncoder.encode(adminPassword));
userRepository.save(admin);
log.info("E2E seed: Admin-Passwort auf konfigurierten Wert zurückgesetzt.");
});
// Always ensure the read-only test user exists, even when seed data was already loaded.
if (userRepository.findByUsername("reader").isEmpty()) {
if (userRepository.findByEmail("reader@familyarchive.local").isEmpty()) {
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
groupRepository.save(UserGroup.builder()
@@ -101,7 +95,7 @@ public class DataInitializer {
.permissions(Set.of("READ_ALL"))
.build()));
userRepository.save(AppUser.builder()
.username("reader")
.email("reader@familyarchive.local")
.password(passwordEncoder.encode("reader123"))
.groups(Set.of(leserGroup))
.build());
@@ -131,7 +125,6 @@ public class DataInitializer {
Tag tagUrlaub = tagRepo.save(Tag.builder().name("Urlaub").build());
// ── Documents ────────────────────────────────────────────────────
// 1. Fully transcribed letter — used by search + detail E2E tests
docRepo.save(Document.builder()
.title("Geburtsurkunde Hans Müller")
.originalFilename("geburtsurkunde_hans.pdf")
@@ -144,7 +137,6 @@ public class DataInitializer {
.transcription("Hiermit wird beurkundet, dass Hans Müller am 12. April 1923 in Berlin geboren wurde.")
.build());
// 2. Letter with multiple receivers and tags — tests multi-receiver display
docRepo.save(Document.builder()
.title("Brief aus dem Krieg")
.originalFilename("brief_krieg_1944.pdf")
@@ -157,7 +149,6 @@ public class DataInitializer {
.transcription("Liebe Anna, ich schreibe dir aus der Front. Es geht mir den Umständen entsprechend gut.")
.build());
// 3. Postcard — no transcription, tests PLACEHOLDER status
docRepo.save(Document.builder()
.title("Urlaubspostkarte Ostsee")
.originalFilename("postkarte_1965.jpg")
@@ -169,7 +160,6 @@ public class DataInitializer {
.tags(Set.of(tagUrlaub))
.build());
// 4. Document with no sender — tests null-sender display ("Unbekannt")
docRepo.save(Document.builder()
.title("Unbekanntes Dokument")
.originalFilename("unbekannt.pdf")
@@ -179,7 +169,6 @@ public class DataInitializer {
.receivers(Set.of(maria))
.build());
// 5. Document with minimal metadata — tests sparse display
docRepo.save(Document.builder()
.title("Scan ohne Titel")
.originalFilename("scan_ohne_titel.pdf")

View File

@@ -67,7 +67,7 @@ public class SecurityConfig {
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
// Erlaubt Login via Browser-Popup oder REST-Header (Authorization: Basic ...)
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults());
.formLogin(form -> form.usernameParameter("email"));
return http.build();
}

View File

@@ -72,7 +72,7 @@ public class AnnotationController {
private UUID resolveUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
AppUser user = userService.findByUsername(authentication.getName());
AppUser user = userService.findByEmail(authentication.getName());
return user != null ? user.getId() : null;
} catch (Exception e) {
log.warn("Could not resolve user for annotation: {}", e.getMessage());

View File

@@ -144,7 +144,7 @@ public class CommentController {
private AppUser resolveUser(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
return userService.findByUsername(authentication.getName());
return userService.findByEmail(authentication.getName());
} catch (Exception e) {
log.warn("Could not resolve user for comment: {}", e.getMessage());
return null;

View File

@@ -100,6 +100,6 @@ public class NotificationController {
// ─── private helpers ──────────────────────────────────────────────────────
private AppUser resolveUser(Authentication authentication) {
return userService.findByUsername(authentication.getName());
return userService.findByEmail(authentication.getName());
}
}

View File

@@ -161,7 +161,7 @@ public class OcrController {
private UUID resolveUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
AppUser user = userService.findByUsername(authentication.getName());
AppUser user = userService.findByEmail(authentication.getName());
return user != null ? user.getId() : null;
} catch (Exception e) {
log.warn("Failed to resolve user ID for authentication: {}", authentication.getName(), e);

View File

@@ -101,7 +101,7 @@ public class TranscriptionBlockController {
if (authentication == null || !authentication.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
AppUser user = userService.findByUsername(authentication.getName());
AppUser user = userService.findByEmail(authentication.getName());
if (user == null) {
throw DomainException.unauthorized("User not found");
}

View File

@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import jakarta.validation.Valid;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
@@ -38,7 +39,7 @@ public class UserController {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
AppUser user = userService.findByUsername(authentication.getName());
AppUser user = userService.findByEmail(authentication.getName());
user.setPassword(null);
return ResponseEntity.ok(user);
}
@@ -46,7 +47,7 @@ public class UserController {
@PutMapping("users/me")
public ResponseEntity<AppUser> updateProfile(Authentication authentication,
@RequestBody UpdateProfileDTO dto) {
AppUser current = userService.findByUsername(authentication.getName());
AppUser current = userService.findByEmail(authentication.getName());
AppUser updated = userService.updateProfile(current.getId(), dto);
updated.setPassword(null);
return ResponseEntity.ok(updated);
@@ -56,7 +57,7 @@ public class UserController {
@ResponseStatus(HttpStatus.NO_CONTENT)
public void changePassword(Authentication authentication,
@RequestBody ChangePasswordDTO dto) {
AppUser current = userService.findByUsername(authentication.getName());
AppUser current = userService.findByEmail(authentication.getName());
userService.changePassword(current.getId(), dto);
}
@@ -77,7 +78,7 @@ public class UserController {
@PostMapping("/users")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> createUser(@RequestBody CreateUserRequest request) {
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.createUserOrUpdate(request));
}

View File

@@ -1,6 +1,8 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import java.time.LocalDate;
@@ -9,7 +11,9 @@ import java.util.UUID;
@Data
public class CreateUserRequest {
private String username;
@NotBlank
@Email
@Pattern(regexp = "^[^:]+$", message = "Email must not contain a colon")
private String email;
private String initialPassword;
private List<UUID> groupIds;

View File

@@ -1,6 +1,9 @@
package org.raddatz.familienarchiv.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
@@ -17,7 +20,7 @@ import java.util.Set;
import java.util.UUID;
@Entity
@Table(name = "users") // Tabellenname in Postgres
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
@@ -30,26 +33,26 @@ public class AppUser {
private UUID id;
@Column(unique = true, nullable = false)
@NotBlank
@Email
@Pattern(regexp = "^[^:]+$", message = "Email must not contain a colon")
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String username;
private String email;
@Column(nullable = false)
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password; // Wird verschlüsselt gespeichert (BCrypt)
private String password;
private String firstName;
private String lastName;
private LocalDate birthDate;
@Column(unique = true)
private String email;
@Column(columnDefinition = "TEXT")
private String contact;
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen
private boolean enabled = true;
@Column(nullable = false)
@Builder.Default
@@ -61,7 +64,6 @@ public class AppUser {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean notifyOnMention = false;
// Ein User kann in mehreren Gruppen sein
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))
@Builder.Default
@@ -77,26 +79,21 @@ public class AppUser {
return false;
}
return this.groups.stream().anyMatch(group -> group.getPermissions().contains(permission));
}
public AppUser updateFromRequest(CreateUserRequest request, PasswordEncoder passwordEncoder, Set<UserGroup> groups) {
if (request.getUsername() != null && !request.getUsername().isBlank()) {
this.username = request.getUsername();
}
public AppUser updateFromRequest(CreateUserRequest request, PasswordEncoder passwordEncoder, Set<UserGroup> groups) {
if (request.getEmail() != null && !request.getEmail().isBlank()) {
this.email = request.getEmail();
}
if (request.getEmail() != null && !request.getEmail().isBlank()) {
this.email = request.getEmail();
}
if (request.getInitialPassword() != null && !request.getInitialPassword().isBlank()) {
this.password = passwordEncoder.encode(request.getInitialPassword());
}
if (request.getInitialPassword() != null && !request.getInitialPassword().isBlank()) {
this.password = passwordEncoder.encode(request.getInitialPassword());
}
if (groups != null && !groups.isEmpty()) {
this.groups = groups;
}
if (groups != null && !groups.isEmpty()) {
this.groups = groups;
return this;
}
return this;
}
}

View File

@@ -13,11 +13,10 @@ import java.util.UUID;
@Repository
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
Optional<AppUser> findByUsername(String username);
Optional<AppUser> findByEmail(String email);
@Query("SELECT u FROM AppUser u WHERE " +
"LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) " +
"OR LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%'))")
List<AppUser> searchByNameOrUsername(@Param("q") String q, Pageable pageable);
}
"LOWER(u.email) LIKE LOWER(CONCAT('%', :q, '%')) " +
"OR LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%'))")
List<AppUser> searchByEmailOrName(@Param("q") String q, Pageable pageable);
}

View File

@@ -175,7 +175,7 @@ public class CommentService {
String first = author.getFirstName();
String last = author.getLastName();
if ((first == null || first.isBlank()) && (last == null || last.isBlank())) {
return author.getUsername();
return author.getEmail();
}
return ((first != null ? first : "") + " " + (last != null ? last : "")).strip();
}

View File

@@ -29,24 +29,22 @@ public class CustomUserDetailsService implements UserDetailsService {
private final AppUserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
AppUser appUser = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User nicht gefunden: " + username));
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
AppUser appUser = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User nicht gefunden: " + email));
// Collect all permissions from all groups; warn about any that don't match a known Permission enum value
var authorities = appUser.getGroups().stream()
.flatMap(group -> group.getPermissions().stream())
.peek(p -> {
if (!KNOWN_PERMISSIONS.contains(p)) {
log.warn("Unknown permission '{}' found in database for user '{}' — it will be granted but never matched by @RequirePermission", p, appUser.getUsername());
log.warn("Unknown permission '{}' found in database for user '{}' — it will be granted but never matched by @RequirePermission", p, appUser.getEmail());
}
})
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet());
// Rückgabe des Standard Spring Security User Objekts
return new User(
appUser.getUsername(),
appUser.getEmail(),
appUser.getPassword(),
appUser.isEnabled(),
true, true, true,

View File

@@ -100,7 +100,7 @@ public class DocumentVersionService {
return null;
}
try {
return userService.findByUsername(auth.getName());
return userService.findByEmail(auth.getName());
} catch (Exception e) {
log.warn("Could not resolve editor for version snapshot: {}", e.getMessage());
return null;
@@ -114,7 +114,7 @@ public class DocumentVersionService {
if (first != null && !first.isBlank() && last != null && !last.isBlank()) {
return first + " " + last;
}
return user.getUsername();
return user.getEmail();
}
private String serializeSnapshot(Document doc) {

View File

@@ -18,6 +18,6 @@ public class UserSearchService {
public List<AppUser> search(String query) {
if (query == null || query.isBlank()) return List.of();
return userRepository.searchByNameOrUsername(query.trim(), PageRequest.of(0, MAX_RESULTS));
return userRepository.searchByEmailOrName(query.trim(), PageRequest.of(0, MAX_RESULTS));
}
}

View File

@@ -36,23 +36,22 @@ public class UserService {
@Transactional
public AppUser createUserOrUpdate(CreateUserRequest request) {
log.info("Creating or updating user: {}", request.getUsername());
log.info("Creating or updating user: {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>();
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
}
Optional<AppUser> existingUser = userRepository.findByUsername(request.getUsername());
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
AppUser user;
if (existingUser.isPresent()) {
log.info("User exists, updating: {}", request.getUsername());
log.info("User exists, updating: {}", request.getEmail());
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
} else {
log.info("Creating new user: {}", request.getUsername());
log.info("Creating new user: {}", request.getEmail());
user = AppUser.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getInitialPassword()))
.groups(groups)
@@ -103,8 +102,8 @@ public class UserService {
}
});
user.setEmail(dto.getEmail().trim());
} else if (dto.getEmail() != null && dto.getEmail().isBlank()) {
user.setEmail(null);
} else if (dto.getEmail() != null) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "Email must not be blank");
}
user.setFirstName(dto.getFirstName());
@@ -126,8 +125,8 @@ public class UserService {
}
});
user.setEmail(dto.getEmail().trim());
} else if (dto.getEmail() != null && dto.getEmail().isBlank()) {
user.setEmail(null);
} else if (dto.getEmail() != null) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "Email must not be blank");
}
user.setFirstName(dto.getFirstName());
@@ -158,9 +157,9 @@ public class UserService {
userRepository.save(user);
}
public AppUser findByUsername(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for username: " + username));
public AppUser findByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for email: " + email));
}
public List<AppUser> getAllUsers() {

View File

@@ -0,0 +1,11 @@
-- Abort if any user has no email address set.
-- All users must have an email before this migration can run.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM users WHERE email IS NULL) THEN
RAISE EXCEPTION 'Migration aborted: some users have no email address. Set emails for all users before running this migration.';
END IF;
END $$;
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users DROP COLUMN username;

View File

@@ -278,9 +278,9 @@ class AnnotationControllerTest {
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_resolvesNullUserId_whenUserServiceThrows() throws Exception {
// findByUsername throws → catch block → resolveUserId returns null
// findByEmail throws → catch block → resolveUserId returns null
UUID docId = UUID.randomUUID();
when(userService.findByUsername(any())).thenThrow(new RuntimeException("DB error"));
when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error"));
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
@@ -296,9 +296,9 @@ class AnnotationControllerTest {
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_resolvesNullUserId_whenUserServiceReturnsNull() throws Exception {
// findByUsername returns null → user != null = false → resolveUserId returns null
// findByEmail returns null → user != null = false → resolveUserId returns null
UUID docId = UUID.randomUUID();
when(userService.findByUsername(any())).thenReturn(null);
when(userService.findByEmail(any())).thenReturn(null);
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();

View File

@@ -269,8 +269,8 @@ class CommentControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postDocumentComment_stillSucceeds_whenUserServiceThrows() throws Exception {
// findByUsername throws → catch block in resolveUser → author null, saves anyway
when(userService.findByUsername(any())).thenThrow(new RuntimeException("DB error"));
// findByEmail throws → catch block in resolveUser → author null, saves anyway
when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error"));
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);

View File

@@ -60,8 +60,8 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser")
void getNotifications_returns200_whenAuthenticatedWithNoPermissions() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
when(userService.findByEmail("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
.thenReturn(new PageImpl<>(List.of()));
@@ -72,12 +72,12 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNotifications_returns200WithList_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
NotificationDTO dto = new NotificationDTO(
UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(),
UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith", "Testdokument");
when(userService.findByUsername("testuser")).thenReturn(user);
when(userService.findByEmail("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
.thenReturn(new PageImpl<>(List.of(dto), PageRequest.of(0, 10), 1));
@@ -89,8 +89,8 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
when(userService.findByEmail("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
.thenReturn(new PageImpl<>(List.of()));
@@ -103,8 +103,8 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNotifications_withTypeAndReadFalse_passesFiltersToService() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
when(userService.findByEmail("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), eq(NotificationType.MENTION), eq(false), any()))
.thenReturn(new PageImpl<>(List.of()));
@@ -148,8 +148,8 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void markAllRead_returns204_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
when(userService.findByEmail("testuser")).thenReturn(user);
mockMvc.perform(post("/api/notifications/read-all"))
.andExpect(status().isNoContent());
@@ -168,10 +168,10 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void markOneRead_returns403_whenNotificationBelongsToDifferentUser() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
UUID notifId = UUID.randomUUID();
when(userService.findByUsername("testuser")).thenReturn(user);
when(userService.findByEmail("testuser")).thenReturn(user);
org.mockito.Mockito.doThrow(
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
.when(notificationService).markRead(notifId, USER_ID);
@@ -198,9 +198,9 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getPreferences_returns200_whenUserHasReadAll() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com")
.notifyOnReply(true).notifyOnMention(false).build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(userService.findByEmail("testuser")).thenReturn(user);
mockMvc.perform(get("/api/users/me/notification-preferences"))
.andExpect(status().isOk())
@@ -211,9 +211,9 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void getPreferences_returns200_whenUserHasWriteAll() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com")
.notifyOnReply(false).notifyOnMention(true).build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(userService.findByEmail("testuser")).thenReturn(user);
mockMvc.perform(get("/api/users/me/notification-preferences"))
.andExpect(status().isOk())
@@ -223,9 +223,9 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"ANNOTATE_ALL"})
void getPreferences_returns200_whenUserHasAnnotateAll() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com")
.notifyOnReply(false).notifyOnMention(false).build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(userService.findByEmail("testuser")).thenReturn(user);
mockMvc.perform(get("/api/users/me/notification-preferences"))
.andExpect(status().isOk());
@@ -234,8 +234,8 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void getNotifications_returns200_whenUserHasOnlyWriteAll() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
when(userService.findByEmail("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
.thenReturn(new PageImpl<>(List.of()));
@@ -248,11 +248,11 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void updatePreferences_persistsBothBooleans() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com")
.notifyOnReply(false).notifyOnMention(false).build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(userService.findByEmail("testuser")).thenReturn(user);
AppUser updated = AppUser.builder().id(USER_ID).username("testuser")
AppUser updated = AppUser.builder().id(USER_ID).email("testuser@example.com")
.notifyOnReply(true).notifyOnMention(true).build();
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
@@ -267,11 +267,11 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void updatePreferences_returns200_whenUserHasWriteAll() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com")
.notifyOnReply(false).notifyOnMention(false).build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(userService.findByEmail("testuser")).thenReturn(user);
AppUser updated = AppUser.builder().id(USER_ID).username("testuser")
AppUser updated = AppUser.builder().id(USER_ID).email("testuser@example.com")
.notifyOnReply(true).notifyOnMention(false).build();
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
@@ -293,8 +293,8 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void countUnread_returns200WithCount_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
when(userService.findByEmail("testuser")).thenReturn(user);
when(notificationService.countUnread(USER_ID)).thenReturn(3L);
mockMvc.perform(get("/api/notifications/unread-count"))
@@ -316,8 +316,8 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void stream_returns200_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
when(userService.findByEmail("testuser")).thenReturn(user);
when(sseEmitterRegistry.register(USER_ID)).thenReturn(new org.springframework.web.servlet.mvc.method.annotation.SseEmitter());
mockMvc.perform(get("/api/notifications/stream")
@@ -330,10 +330,10 @@ class NotificationControllerTest {
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void markOneRead_returns404_whenNotificationDoesNotExist() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
UUID notifId = UUID.randomUUID();
when(userService.findByUsername("testuser")).thenReturn(user);
when(userService.findByEmail("testuser")).thenReturn(user);
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
.when(notificationService).markRead(notifId, USER_ID);

View File

@@ -54,7 +54,7 @@ class TranscriptionBlockControllerTest {
"{\"blockIds\":[\"" + UUID.randomUUID() + "\",\"" + UUID.randomUUID() + "\"]}";
private AppUser mockUser() {
return AppUser.builder().id(UUID.randomUUID()).username("user").build();
return AppUser.builder().id(UUID.randomUUID()).email("user@example.com").build();
}
private TranscriptionBlock sampleBlock() {
@@ -161,7 +161,7 @@ class TranscriptionBlockControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception {
when(userService.findByUsername(any())).thenReturn(mockUser());
when(userService.findByEmail(any())).thenReturn(mockUser());
when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock());
mockMvc.perform(post(URL_BASE)
@@ -175,7 +175,7 @@ class TranscriptionBlockControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByUsername(any())).thenReturn(null);
when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON)
@@ -209,7 +209,7 @@ class TranscriptionBlockControllerTest {
updated.setText("Neue Fassung");
updated.setLabel("Anrede");
when(userService.findByUsername(any())).thenReturn(mockUser());
when(userService.findByEmail(any())).thenReturn(mockUser());
when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any()))
.thenReturn(updated);
@@ -224,7 +224,7 @@ class TranscriptionBlockControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {
when(userService.findByUsername(any())).thenReturn(mockUser());
when(userService.findByEmail(any())).thenReturn(mockUser());
when(transcriptionService.updateBlock(any(), any(), any(), any()))
.thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"));
@@ -237,7 +237,7 @@ class TranscriptionBlockControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByUsername(any())).thenReturn(null);
when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON)

View File

@@ -19,6 +19,7 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -31,33 +32,32 @@ class UserControllerTest {
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/users/me ────────────────────────────────────────────────────
// ─── GET /api/users/me ────────────────────────────────────────────────────────
@Test
void getCurrentUser_returns401_whenUnauthenticated() throws Exception {
// authentication == null → returns 401 (covers null/!isAuthenticated branch)
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "anna")
@WithMockUser(username = "anna@example.com")
void getCurrentUser_returns200_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
when(userService.findByUsername("anna")).thenReturn(user);
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("anna@example.com").build();
when(userService.findByEmail("anna@example.com")).thenReturn(user);
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("anna"));
.andExpect(jsonPath("$.email").value("anna@example.com"));
}
// ─── GET /api/users/{id} ──────────────────────────────────────────────────
@Test
@WithMockUser(username = "reader")
@WithMockUser(username = "reader@example.com")
void getUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
UUID id = UUID.randomUUID();
AppUser target = AppUser.builder().id(id).username("target").build();
AppUser target = AppUser.builder().id(id).email("target@example.com").build();
when(userService.getById(id)).thenReturn(target);
mockMvc.perform(get("/api/users/" + id))
@@ -65,14 +65,43 @@ class UserControllerTest {
}
@Test
@WithMockUser(username = "admin", authorities = {"ADMIN_USER"})
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
void getUser_returns200_whenCallerHasAdminUserPermission() throws Exception {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("target").build();
AppUser user = AppUser.builder().id(id).email("target@example.com").build();
when(userService.getById(id)).thenReturn(user);
mockMvc.perform(get("/api/users/" + id))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("target"));
.andExpect(jsonPath("$.email").value("target@example.com"));
}
// ─── POST /api/users ──────────────────────────────────────────────────────
@Test
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
void createUser_returns400_whenEmailIsNotValidEmailFormat() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"notanemail\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
void createUser_returns400_whenEmailContainsColon() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"user:name@example.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
void createUser_returns400_whenEmailIsBlank() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest());
}
}

View File

@@ -60,7 +60,7 @@ class UserSearchControllerTest {
@WithMockUser(authorities = {"READ_ALL"})
void search_returns200_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(UUID.randomUUID())
.firstName("Hans").lastName("Mueller").username("hans").build();
.firstName("Hans").lastName("Mueller").email("hans@example.com").build();
when(userSearchService.search("Hans")).thenReturn(List.of(user));
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
@@ -83,7 +83,7 @@ class UserSearchControllerTest {
void search_returnsAtMostTenResults() throws Exception {
List<AppUser> elevenUsers = IntStream.range(0, 11)
.mapToObj(i -> AppUser.builder().id(UUID.randomUUID())
.firstName("User").lastName(String.valueOf(i)).username("u" + i).build())
.firstName("User").lastName(String.valueOf(i)).email("u" + i + "@example.com").build())
.toList();
when(userSearchService.search(anyString())).thenReturn(elevenUsers.subList(0, 10));

View File

@@ -274,6 +274,34 @@ class MigrationIntegrationTest {
assertThat(rows2).isEqualTo(1);
}
// ─── V44: email NOT NULL constraint ──────────────────────────────────────
@Test
void v44_emailNotNullConstraint_rejectsInsertWithNullEmail() {
assertThatThrownBy(() ->
jdbc.update("""
INSERT INTO users (id, email, password, enabled, notify_on_reply, notify_on_mention)
VALUES (gen_random_uuid(), NULL, 'hash', true, false, false)
""")
).isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void v44_emailUniqueConstraint_rejectsDuplicateEmail() {
String email = "unique-test-" + UUID.randomUUID() + "@example.com";
jdbc.update("""
INSERT INTO users (id, email, password, enabled, notify_on_reply, notify_on_mention)
VALUES (gen_random_uuid(), ?, 'hash', true, false, false)
""", email);
assertThatThrownBy(() ->
jdbc.update("""
INSERT INTO users (id, email, password, enabled, notify_on_reply, notify_on_mention)
VALUES (gen_random_uuid(), ?, 'hash', true, false, false)
""", email)
).isInstanceOf(DataIntegrityViolationException.class);
}
// ─── helpers ─────────────────────────────────────────────────────────────
private UUID createPerson(String firstName, String lastName) {

View File

@@ -31,8 +31,8 @@ class NotificationRepositoryTest {
void setUp() {
notificationRepository.deleteAll();
appUserRepository.deleteAll();
userA = appUserRepository.save(AppUser.builder().username("userA").password("pw").build());
userB = appUserRepository.save(AppUser.builder().username("userB").password("pw").build());
userA = appUserRepository.save(AppUser.builder().email("userA@example.com").password("pw").build());
userB = appUserRepository.save(AppUser.builder().email("userB@example.com").password("pw").build());
}
// ─── findByRecipientIdAndTypeAndReadFalse ─────────────────────────────────

View File

@@ -43,7 +43,7 @@ class CommentServiceTest {
void postComment_capturesAuthorNameAtWriteTime() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder()
.id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("Müller").build();
.id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("Müller").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved);
@@ -56,7 +56,7 @@ class CommentServiceTest {
@Test
void postComment_fallsBackToUsername_whenNamesAreBlank() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans42").build();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans42@example.com").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved);
@@ -70,8 +70,8 @@ class CommentServiceTest {
void postComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
UUID docId = UUID.randomUUID();
UUID mentionedId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("M").build();
AppUser mentioned = AppUser.builder().id(mentionedId).username("anna").firstName("Anna").lastName("S").build();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hey @Anna S").build();
@@ -89,7 +89,7 @@ class CommentServiceTest {
void replyToComment_throwsNotFound_whenTargetCommentMissing() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("anna@example.com").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", List.of(), author))
@@ -104,7 +104,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
UUID replyId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("anna@example.com").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
@@ -127,7 +127,7 @@ class CommentServiceTest {
void replyToComment_usesDirectComment_whenReplyingToTopLevel() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("anna@example.com").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
@@ -147,7 +147,7 @@ class CommentServiceTest {
void replyToComment_triggersNotifyReply_afterSave() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("anna@example.com").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
@@ -168,8 +168,8 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
UUID mentionedId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
AppUser mentioned = AppUser.builder().id(mentionedId).username("bob").firstName("Bob").lastName("J").build();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("anna@example.com").build();
AppUser mentioned = AppUser.builder().id(mentionedId).email("bob@example.com").firstName("Bob").lastName("J").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
@@ -193,7 +193,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
AppUser other = AppUser.builder().id(UUID.randomUUID()).email("other@example.com").build();
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(ownerId).content("Original").authorName("Hans").build();
@@ -211,7 +211,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID authorId = UUID.randomUUID();
AppUser author = AppUser.builder().id(authorId).username("hans").build();
AppUser author = AppUser.builder().id(authorId).email("hans@example.com").build();
LocalDateTime created = LocalDateTime.now().minusMinutes(5);
DocumentComment comment = DocumentComment.builder()
@@ -233,7 +233,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
AppUser other = AppUser.builder().id(UUID.randomUUID()).email("other@example.com").build();
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
@@ -251,7 +251,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID authorId = UUID.randomUUID();
AppUser author = AppUser.builder().id(authorId).username("hans").build();
AppUser author = AppUser.builder().id(authorId).email("hans@example.com").build();
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(authorId).authorName("Hans").content("X").build();
@@ -306,7 +306,7 @@ class CommentServiceTest {
void replyToComment_handlesNullAuthorId_inExistingReply() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").firstName("Anna").lastName("S").build();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("anna@example.com").firstName("Anna").lastName("S").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID()).content("Root").authorName("Root").build();
@@ -331,7 +331,7 @@ class CommentServiceTest {
@Test
void postComment_fallsBackToUsername_whenFirstNameBlankAndLastNameNull() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName(" ").lastName(null).build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
@@ -345,7 +345,7 @@ class CommentServiceTest {
@Test
void postComment_fallsBackToUsername_whenFirstNameNullAndLastNameBlank() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName(null).lastName(" ").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
@@ -359,7 +359,7 @@ class CommentServiceTest {
@Test
void postComment_includesOnlyFirstName_whenLastNameIsNull() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName("Hans").lastName(null).build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans").content("Hi").build();
@@ -374,7 +374,7 @@ class CommentServiceTest {
@Test
void postComment_includesOnlyLastName_whenFirstNameIsNull() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName(null).lastName("Müller").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Müller").content("Hi").build();
@@ -391,7 +391,7 @@ class CommentServiceTest {
@Test
void postComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans")
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com")
.firstName("Hans").lastName("M").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hi").build();
@@ -409,7 +409,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
UUID existingReplyAuthorId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("anna@example.com").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID())
@@ -437,7 +437,7 @@ class CommentServiceTest {
void replyToComment_excludesNullAuthorIds_fromParticipantSet() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("anna@example.com").build();
// Root with null authorId
DocumentComment root = DocumentComment.builder()
@@ -480,7 +480,7 @@ class CommentServiceTest {
private AppUser buildAdmin() {
return AppUser.builder()
.id(UUID.randomUUID())
.username("admin")
.email("admin@example.com")
.groups(Set.of(UserGroup.builder()
.id(UUID.randomUUID())
.name("admins")
@@ -510,7 +510,7 @@ class CommentServiceTest {
void postBlockComment_setsBlockIdOnComment() {
UUID documentId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("felix").firstName("Felix").lastName("Brandt").build();
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
when(commentRepository.save(any())).thenAnswer(inv -> {
DocumentComment c = inv.getArgument(0);
c.setId(UUID.randomUUID());

View File

@@ -8,7 +8,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -29,40 +28,40 @@ class CustomUserDetailsServiceTest {
// ─── loadUserByUsername — not found ──────────────────────────────────────
@Test
void loadUserByUsername_throwsUsernameNotFoundException_whenUserNotFound() {
when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty());
void loadUserByEmail_throwsUsernameNotFoundException_whenUserNotFound() {
when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.loadUserByUsername("ghost"))
assertThatThrownBy(() -> service.loadUserByUsername("ghost@example.com"))
.isInstanceOf(UsernameNotFoundException.class)
.hasMessageContaining("ghost");
.hasMessageContaining("ghost@example.com");
}
// ─── loadUserByUsername — happy path ─────────────────────────────────────
@Test
void loadUserByUsername_returnsUserDetails_withMappedAuthorities() {
void loadUserByEmail_returnsUserDetails_withEmailAsPrincipal() {
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins")
.permissions(Set.of("READ_ALL", "WRITE_ALL")).build();
AppUser user = AppUser.builder().id(UUID.randomUUID())
.username("admin").password("hashed").enabled(true)
.email("admin@example.com").password("hashed").enabled(true)
.groups(Set.of(group)).build();
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
when(userRepository.findByEmail("admin@example.com")).thenReturn(Optional.of(user));
UserDetails details = service.loadUserByUsername("admin");
UserDetails details = service.loadUserByUsername("admin@example.com");
assertThat(details.getUsername()).isEqualTo("admin");
assertThat(details.getUsername()).isEqualTo("admin@example.com");
assertThat(details.getAuthorities()).extracting("authority")
.contains("READ_ALL", "WRITE_ALL");
}
@Test
void loadUserByUsername_returnsEmptyAuthorities_whenUserHasNoGroups() {
void loadUserByEmail_returnsEmptyAuthorities_whenUserHasNoGroups() {
AppUser user = AppUser.builder().id(UUID.randomUUID())
.username("viewer").password("hashed").enabled(true)
.email("viewer@example.com").password("hashed").enabled(true)
.groups(Set.of()).build();
when(userRepository.findByUsername("viewer")).thenReturn(Optional.of(user));
when(userRepository.findByEmail("viewer@example.com")).thenReturn(Optional.of(user));
UserDetails details = service.loadUserByUsername("viewer");
UserDetails details = service.loadUserByUsername("viewer@example.com");
assertThat(details.getAuthorities()).isEmpty();
}
@@ -70,16 +69,15 @@ class CustomUserDetailsServiceTest {
// ─── loadUserByUsername — unknown permission ──────────────────────────────
@Test
void loadUserByUsername_grantsUnknownPermission_butLogsWarning() {
// Unknown permissions should still be granted (logged as warning, not silently dropped)
void loadUserByEmail_grantsUnknownPermission_butLogsWarning() {
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("CustomGroup")
.permissions(Set.of("UNKNOWN_CUSTOM_PERM")).build();
AppUser user = AppUser.builder().id(UUID.randomUUID())
.username("custom").password("hashed").enabled(true)
.email("custom@example.com").password("hashed").enabled(true)
.groups(Set.of(group)).build();
when(userRepository.findByUsername("custom")).thenReturn(Optional.of(user));
when(userRepository.findByEmail("custom@example.com")).thenReturn(Optional.of(user));
UserDetails details = service.loadUserByUsername("custom");
UserDetails details = service.loadUserByUsername("custom@example.com");
assertThat(details.getAuthorities()).extracting("authority")
.contains("UNKNOWN_CUSTOM_PERM");
@@ -88,13 +86,13 @@ class CustomUserDetailsServiceTest {
// ─── loadUserByUsername — disabled user ───────────────────────────────────
@Test
void loadUserByUsername_returnsDisabledUser_whenUserIsDisabled() {
void loadUserByEmail_returnsDisabledUser_whenUserIsDisabled() {
AppUser user = AppUser.builder().id(UUID.randomUUID())
.username("disabled").password("hashed").enabled(false)
.email("disabled@example.com").password("hashed").enabled(false)
.groups(Set.of()).build();
when(userRepository.findByUsername("disabled")).thenReturn(Optional.of(user));
when(userRepository.findByEmail("disabled@example.com")).thenReturn(Optional.of(user));
UserDetails details = service.loadUserByUsername("disabled");
UserDetails details = service.loadUserByUsername("disabled@example.com");
assertThat(details.isEnabled()).isFalse();
}
@@ -102,17 +100,17 @@ class CustomUserDetailsServiceTest {
// ─── loadUserByUsername — multi-group permission merge ────────────────────
@Test
void loadUserByUsername_mergesPermissionsFromMultipleGroups() {
void loadUserByEmail_mergesPermissionsFromMultipleGroups() {
UserGroup g1 = UserGroup.builder().id(UUID.randomUUID()).name("Readers")
.permissions(Set.of("READ_ALL")).build();
UserGroup g2 = UserGroup.builder().id(UUID.randomUUID()).name("Writers")
.permissions(Set.of("WRITE_ALL")).build();
AppUser user = AppUser.builder().id(UUID.randomUUID())
.username("multi").password("hashed").enabled(true)
.email("multi@example.com").password("hashed").enabled(true)
.groups(Set.of(g1, g2)).build();
when(userRepository.findByUsername("multi")).thenReturn(Optional.of(user));
when(userRepository.findByEmail("multi@example.com")).thenReturn(Optional.of(user));
UserDetails details = service.loadUserByUsername("multi");
UserDetails details = service.loadUserByUsername("multi@example.com");
assertThat(details.getAuthorities()).extracting("authority")
.containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");

View File

@@ -53,8 +53,8 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_usesFirstAndLastName_whenBothPresent() {
authenticateAs("emma");
when(userService.findByUsername("emma")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).username("emma")
when(userService.findByEmail("emma")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).email("emma@example.com")
.firstName("Emma").lastName("Müller").build());
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -67,10 +67,10 @@ class DocumentVersionServiceTest {
}
@Test
void recordVersion_usesUsername_whenNamesAreBlank() {
void recordVersion_usesEmail_whenNamesAreBlank() {
authenticateAs("otto99");
when(userService.findByUsername("otto99")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).username("otto99")
when(userService.findByEmail("otto99")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).email("otto99@example.com")
.firstName(null).lastName(null).build());
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -79,7 +79,7 @@ class DocumentVersionServiceTest {
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("otto99");
assertThat(captor.getValue().getEditorName()).isEqualTo("otto99@example.com");
}
// ─── recordVersion — snapshot ─────────────────────────────────────────────
@@ -87,7 +87,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_savesSnapshotContainingTitle() {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -110,7 +110,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_changedFieldsIsEmpty_forFirstVersion() {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -124,7 +124,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_includesTitleInChangedFields_whenTitleChanged() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
Document oldDoc = Document.builder().id(UUID.randomUUID()).title("Alt").build();
@@ -154,7 +154,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_doesNotIncludeUnchangedFields_inChangedFields() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
@@ -181,7 +181,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_tracksSenderChange() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
@@ -209,7 +209,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_tracksReceiverChange() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
@@ -236,7 +236,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_tracksTagChange() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
@@ -410,7 +410,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_usesUnknown_whenUserServiceThrows() {
authenticateAs("missinguser");
when(userService.findByUsername("missinguser")).thenThrow(new RuntimeException("not found"));
when(userService.findByEmail("missinguser")).thenThrow(new RuntimeException("not found"));
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -424,10 +424,10 @@ class DocumentVersionServiceTest {
// ─── recordVersion — buildEditorName edge cases ───────────────────────────
@Test
void recordVersion_usesUsername_whenFirstNameIsNotBlankButLastNameIsNull() {
void recordVersion_usesEmail_whenFirstNameIsNotBlankButLastNameIsNull() {
authenticateAs("user42");
when(userService.findByUsername("user42")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).username("user42")
when(userService.findByEmail("user42")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName("Hans").lastName(null).build());
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -436,14 +436,14 @@ class DocumentVersionServiceTest {
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
assertThat(captor.getValue().getEditorName()).isEqualTo("user42@example.com");
}
@Test
void recordVersion_usesUsername_whenFirstNameIsBlankButLastNameIsPresent() {
void recordVersion_usesEmail_whenFirstNameIsBlankButLastNameIsPresent() {
authenticateAs("user42");
when(userService.findByUsername("user42")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).username("user42")
when(userService.findByEmail("user42")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName(" ").lastName("Müller").build());
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -452,14 +452,14 @@ class DocumentVersionServiceTest {
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
assertThat(captor.getValue().getEditorName()).isEqualTo("user42@example.com");
}
@Test
void recordVersion_usesUsername_whenLastNameIsBlankButFirstNameIsPresent() {
void recordVersion_usesEmail_whenLastNameIsBlankButFirstNameIsPresent() {
authenticateAs("user42");
when(userService.findByUsername("user42")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).username("user42")
when(userService.findByEmail("user42")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
.firstName("Hans").lastName(" ").build());
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -468,7 +468,7 @@ class DocumentVersionServiceTest {
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
assertThat(captor.getValue().getEditorName()).isEqualTo("user42@example.com");
}
// ─── recordVersion — computeChangedFields with corrupt snapshot ──────────
@@ -476,7 +476,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_returnsEmptyChangedFields_whenPreviousSnapshotIsInvalidJson() {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
UUID docId = UUID.randomUUID();
DocumentVersion previous = DocumentVersion.builder()
@@ -499,7 +499,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_tracksSenderAdded_whenPreviousHadNoSender() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
@@ -525,7 +525,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_tracksReceiversAdded_whenPreviousHadNone() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
@@ -551,7 +551,7 @@ class DocumentVersionServiceTest {
@Test
void recordVersion_tracksTagsAdded_whenPreviousHadNone() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
@@ -580,7 +580,7 @@ class DocumentVersionServiceTest {
void recordVersion_senderChangedToPresent_whenPreviousSenderHasNullId() throws Exception {
// Covers: prevSender instanceof Map = true, but id == null → prevId = null
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
UUID docId = UUID.randomUUID();
// Manually craft a JSON where sender object exists but id is null
@@ -610,7 +610,7 @@ class DocumentVersionServiceTest {
void recordVersion_doesNotTrackSender_whenSenderUnchanged() throws Exception {
// Covers: !Objects.equals(currentId, prevId) = false → don't add "sender"
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
@@ -641,7 +641,7 @@ class DocumentVersionServiceTest {
void recordVersion_tracksDocumentDate_whenCurrentDocHasNonNullDate() throws Exception {
// current.getDocumentDate() != null = true → ternary true branch in computeChangedFields
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
@@ -671,7 +671,7 @@ class DocumentVersionServiceTest {
void recordVersion_tracksReceivers_whenPreviousSnapshotHasNullReceivers() throws Exception {
// prevReceivers NOT instanceof List<?> → prevIds = Set.of() → if currentIds differ → added
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
UUID docId = UUID.randomUUID();
// Craft snapshot where "receivers" is JSON null → deserialized as null, NOT a List
@@ -697,7 +697,7 @@ class DocumentVersionServiceTest {
void recordVersion_tracksTags_whenPreviousSnapshotHasNullTags() throws Exception {
// prevTags NOT instanceof List<?> → prevNames = Set.of() → if currentNames differ → added
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
when(userService.findByEmail("user1")).thenReturn(stubUser("user1"));
UUID docId = UUID.randomUUID();
// Craft snapshot where "tags" is JSON null → deserialized as null, NOT a List
@@ -741,8 +741,8 @@ class DocumentVersionServiceTest {
new UsernamePasswordAuthenticationToken(username, null, List.of()));
}
private AppUser stubUser(String username) {
return AppUser.builder().id(UUID.randomUUID()).username(username)
private AppUser stubUser(String email) {
return AppUser.builder().id(UUID.randomUUID()).email(email)
.firstName(null).lastName(null).build();
}

View File

@@ -50,13 +50,13 @@ class NotificationServiceTest {
void setUp() {
notificationService = new NotificationService(notificationRepository, userService, documentService, Optional.of(mailSender), sseEmitterRegistry);
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
userA = AppUser.builder().id(UUID.randomUUID()).email("userA@example.com")
.firstName("Anna").lastName("Smith").email("a@test.com")
.notifyOnReply(false).notifyOnMention(false).build();
userB = AppUser.builder().id(UUID.randomUUID()).username("userB")
userB = AppUser.builder().id(UUID.randomUUID()).email("userB@example.com")
.firstName("Bob").lastName("Jones").email("b@test.com")
.notifyOnReply(false).notifyOnMention(false).build();
userC = AppUser.builder().id(UUID.randomUUID()).username("userC")
userC = AppUser.builder().id(UUID.randomUUID()).email("userC@example.com")
.firstName("Clara").lastName("Doe").email("c@test.com")
.notifyOnReply(false).notifyOnMention(false).build();
}

View File

@@ -42,7 +42,7 @@ class PasswordResetServiceTest {
private AppUser makeUser(String email) {
return AppUser.builder()
.id(UUID.randomUUID())
.username("testuser")
.email("testuser@example.com")
.email(email)
.password("hashed")
.build();

View File

@@ -32,7 +32,7 @@ class UserSearchServiceTest {
List<AppUser> result = userSearchService.search(null);
assertThat(result).isEmpty();
verify(userRepository, never()).searchByNameOrUsername(any(), any());
verify(userRepository, never()).searchByEmailOrName(any(), any());
}
@Test
@@ -40,28 +40,28 @@ class UserSearchServiceTest {
List<AppUser> result = userSearchService.search(" ");
assertThat(result).isEmpty();
verify(userRepository, never()).searchByNameOrUsername(any(), any());
verify(userRepository, never()).searchByEmailOrName(any(), any());
}
@Test
void search_delegatesToRepository_whenQueryIsNonBlank() {
AppUser user = AppUser.builder().id(UUID.randomUUID()).username("hans").build();
when(userRepository.searchByNameOrUsername(eq("hans"), any(PageRequest.class)))
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").build();
when(userRepository.searchByEmailOrName(eq("hans"), any(PageRequest.class)))
.thenReturn(List.of(user));
List<AppUser> result = userSearchService.search("hans");
assertThat(result).containsExactly(user);
verify(userRepository).searchByNameOrUsername(eq("hans"), any(PageRequest.class));
verify(userRepository).searchByEmailOrName(eq("hans"), any(PageRequest.class));
}
@Test
void search_trimsQuery_beforeDelegating() {
when(userRepository.searchByNameOrUsername(eq("hans"), any(PageRequest.class)))
when(userRepository.searchByEmailOrName(eq("hans"), any(PageRequest.class)))
.thenReturn(List.of());
userSearchService.search(" hans ");
verify(userRepository).searchByNameOrUsername(eq("hans"), any(PageRequest.class));
verify(userRepository).searchByEmailOrName(eq("hans"), any(PageRequest.class));
}
}

View File

@@ -35,22 +35,22 @@ class UserServiceTest {
@Mock PasswordEncoder passwordEncoder;
@InjectMocks UserService userService;
// ─── findByUsername ───────────────────────────────────────────────────────
// ─── findByEmail ──────────────────────────────────────────────────────────
@Test
void findByUsername_throwsNotFound_whenMissing() {
when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty());
void findByEmail_throwsNotFound_whenMissing() {
when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.findByUsername("ghost"))
assertThatThrownBy(() -> userService.findByEmail("ghost@example.com"))
.isInstanceOf(DomainException.class);
}
@Test
void findByUsername_returnsUser_whenFound() {
AppUser user = AppUser.builder().id(UUID.randomUUID()).username("admin").build();
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
void findByEmail_returnsUser_whenFound() {
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("admin@example.com").build();
when(userRepository.findByEmail("admin@example.com")).thenReturn(Optional.of(user));
assertThat(userService.findByUsername("admin")).isEqualTo(user);
assertThat(userService.findByEmail("admin@example.com")).isEqualTo(user);
}
// ─── deleteUser ───────────────────────────────────────────────────────────
@@ -67,7 +67,7 @@ class UserServiceTest {
@Test
void deleteUser_deletesUser_whenFound() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("gast").build();
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
userService.deleteUser(id);
@@ -80,14 +80,13 @@ class UserServiceTest {
@Test
void createUserOrUpdate_createsNewUser_whenNotExists() {
CreateUserRequest req = new CreateUserRequest();
req.setUsername("newuser");
req.setEmail("new@example.com");
req.setInitialPassword("secret");
req.setGroupIds(List.of());
when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
when(passwordEncoder.encode("secret")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(req);
@@ -99,19 +98,17 @@ class UserServiceTest {
@Test
void createUserOrUpdate_updatesExistingUser_whenFound() {
CreateUserRequest req = new CreateUserRequest();
req.setUsername("existing");
req.setEmail("existing@example.com");
req.setInitialPassword("newpass");
req.setGroupIds(List.of());
AppUser existing = AppUser.builder().id(UUID.randomUUID()).username("existing").build();
when(userRepository.findByUsername("existing")).thenReturn(Optional.of(existing));
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build();
when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing));
when(passwordEncoder.encode(any())).thenReturn("encoded");
when(userRepository.save(any())).thenReturn(existing);
userService.createUserOrUpdate(req);
// save called once with the updated existing user (no new user created)
verify(userRepository, times(1)).save(existing);
}
@@ -129,7 +126,7 @@ class UserServiceTest {
@Test
void getById_returnsUser_whenFound() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
AppUser user = AppUser.builder().id(id).email("max@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
assertThat(userService.getById(id)).isEqualTo(user);
@@ -140,7 +137,7 @@ class UserServiceTest {
@Test
void updateProfile_updatesFields() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
AppUser user = AppUser.builder().id(id).email("max@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.empty());
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -158,8 +155,8 @@ class UserServiceTest {
void updateProfile_throwsConflict_whenEmailTakenByAnotherUser() {
UUID id = UUID.randomUUID();
UUID otherId = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
AppUser other = AppUser.builder().id(otherId).username("anna").email("taken@example.com").build();
AppUser user = AppUser.builder().id(id).email("max@example.com").build();
AppUser other = AppUser.builder().id(otherId).email("taken@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("taken@example.com")).thenReturn(Optional.of(other));
@@ -174,7 +171,7 @@ class UserServiceTest {
@Test
void updateProfile_allowsSameEmailForSameUser() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").email("max@example.com").build();
AppUser user = AppUser.builder().id(id).email("max@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("max@example.com")).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -191,7 +188,7 @@ class UserServiceTest {
@Test
void changePassword_throwsBadRequest_whenCurrentPasswordWrong() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
AppUser user = AppUser.builder().id(id).email("max@example.com").password("hashed").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(passwordEncoder.matches("wrong", "hashed")).thenReturn(false);
@@ -206,7 +203,7 @@ class UserServiceTest {
@Test
void changePassword_updatesHash_whenCurrentPasswordCorrect() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").password("hashed").build();
AppUser user = AppUser.builder().id(id).email("max@example.com").password("hashed").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(passwordEncoder.matches("correct", "hashed")).thenReturn(true);
when(passwordEncoder.encode("newpass")).thenReturn("newHash");
@@ -224,7 +221,7 @@ class UserServiceTest {
@Test
void adminUpdateUser_updatesNameFields() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").build();
AppUser user = AppUser.builder().id(id).email("admin@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -241,12 +238,12 @@ class UserServiceTest {
void adminUpdateUser_preservesGroups_whenGroupIdsIsNull() {
UUID id = UUID.randomUUID();
UserGroup adminGroup = UserGroup.builder().id(UUID.randomUUID()).name("Administrators").build();
AppUser user = AppUser.builder().id(id).username("admin").groups(Set.of(adminGroup)).build();
AppUser user = AppUser.builder().id(id).email("admin@example.com").groups(Set.of(adminGroup)).build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada"); // groupIds left null → don't change groups
dto.setFirstName("Ada");
AppUser result = userService.adminUpdateUser(id, dto);
@@ -258,7 +255,7 @@ class UserServiceTest {
UUID id = UUID.randomUUID();
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").build();
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").build();
AppUser user = AppUser.builder().id(id).username("max").groups(Set.of(oldGroup)).build();
AppUser user = AppUser.builder().id(id).email("max@example.com").groups(Set.of(oldGroup)).build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -273,18 +270,15 @@ class UserServiceTest {
@Test
void adminUpdateUser_clearsGroups_whenGroupIdsIsEmptyList() {
// Sending groupIds:[] is the explicit "remove from all groups" signal.
// The frontend must NEVER send [] accidentally — it must always include
// the currently-selected group checkboxes.
UUID id = UUID.randomUUID();
UserGroup adminGroup = UserGroup.builder().id(UUID.randomUUID()).name("Administrators").build();
AppUser user = AppUser.builder().id(id).username("admin").groups(Set.of(adminGroup)).build();
AppUser user = AppUser.builder().id(id).email("admin@example.com").groups(Set.of(adminGroup)).build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(groupRepository.findAllById(List.of())).thenReturn(List.of());
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of()); // empty list → intentional "remove all groups"
dto.setGroupIds(List.of());
AppUser result = userService.adminUpdateUser(id, dto);
@@ -308,15 +302,14 @@ class UserServiceTest {
void createUserOrUpdate_loadsGroups_whenGroupIdsNonEmpty() {
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
CreateUserRequest req = new CreateUserRequest();
req.setUsername("newuser");
req.setEmail("u@example.com");
req.setInitialPassword("pass");
req.setGroupIds(List.of(group.getId()));
when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
when(userRepository.findByEmail("u@example.com")).thenReturn(Optional.empty());
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
when(passwordEncoder.encode("pass")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(req);
@@ -325,32 +318,31 @@ class UserServiceTest {
verify(groupRepository).findAllById(List.of(group.getId()));
}
// ─── updateProfile — email edge cases ─────────────────────────────────────
// ─── updateProfile — blank email ──────────────────────────────────────────
@Test
void updateProfile_setsEmailToNull_whenEmailIsBlank() {
void updateProfile_throwsBadRequest_whenEmailIsBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").email("old@example.com").build();
AppUser user = AppUser.builder().id(id).email("old@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setEmail(" "); // blank — should clear email
dto.setEmail(" ");
AppUser result = userService.updateProfile(id, dto);
assertThat(result.getEmail()).isNull();
assertThatThrownBy(() -> userService.updateProfile(id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("blank");
}
@Test
void updateProfile_doesNotChangeEmail_whenEmailDtoIsNull() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").email("keep@example.com").build();
AppUser user = AppUser.builder().id(id).email("keep@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setEmail(null); // null — no change
dto.setEmail(null);
AppUser result = userService.updateProfile(id, dto);
@@ -360,7 +352,7 @@ class UserServiceTest {
@Test
void updateProfile_setsContactToNull_whenContactIsBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
AppUser user = AppUser.builder().id(id).email("max@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -377,7 +369,7 @@ class UserServiceTest {
@Test
void adminUpdateUser_setsPassword_whenNewPasswordProvided() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").password("old").build();
AppUser user = AppUser.builder().id(id).email("admin@example.com").password("old").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(passwordEncoder.encode("newSecret")).thenReturn("newHashed");
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -393,7 +385,7 @@ class UserServiceTest {
@Test
void adminUpdateUser_doesNotChangePassword_whenNewPasswordIsBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").password("original").build();
AppUser user = AppUser.builder().id(id).email("admin@example.com").password("original").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -407,26 +399,25 @@ class UserServiceTest {
}
@Test
void adminUpdateUser_setsEmailToNull_whenEmailIsBlank() {
void adminUpdateUser_throwsBadRequest_whenEmailIsBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").email("old@example.com").build();
AppUser user = AppUser.builder().id(id).email("old@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(" ");
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getEmail()).isNull();
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("blank");
}
@Test
void adminUpdateUser_throwsConflict_whenEmailTakenByAnotherUser() {
UUID id = UUID.randomUUID();
UUID otherId = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").build();
AppUser other = AppUser.builder().id(otherId).username("anna").email("taken@example.com").build();
AppUser user = AppUser.builder().id(id).email("admin@example.com").build();
AppUser other = AppUser.builder().id(otherId).email("taken@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("taken@example.com")).thenReturn(Optional.of(other));
@@ -496,14 +487,13 @@ class UserServiceTest {
@Test
void createUserOrUpdate_doesNotLoadGroups_whenGroupIdsIsEmpty() {
CreateUserRequest req = new CreateUserRequest();
req.setUsername("newuser");
req.setEmail("u@example.com");
req.setInitialPassword("pass");
req.setGroupIds(List.of()); // empty, not null
req.setGroupIds(List.of());
when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
when(userRepository.findByEmail("u@example.com")).thenReturn(Optional.empty());
when(passwordEncoder.encode("pass")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(req);
@@ -511,12 +501,12 @@ class UserServiceTest {
verify(groupRepository, never()).findAllById(any());
}
// ─── updateProfile — contact null ─────────────────────────────────────────
// ─── updateProfile — contact ──────────────────────────────────────────────
@Test
void updateProfile_setsTrimmedContact_whenContactIsNonBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
AppUser user = AppUser.builder().id(id).email("max@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -531,7 +521,7 @@ class UserServiceTest {
@Test
void updateProfile_setsNullContact_whenContactIsNull() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").contact("old contact").build();
AppUser user = AppUser.builder().id(id).email("max@example.com").contact("old contact").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -546,15 +536,14 @@ class UserServiceTest {
@Test
void updateProfile_allowsSameEmail_whenEmailBelongsToSameUser() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").email("me@example.com").build();
AppUser user = AppUser.builder().id(id).email("me@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("me@example.com")).thenReturn(Optional.of(user)); // same user
when(userRepository.findByEmail("me@example.com")).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setEmail("me@example.com");
// Must not throw
AppUser result = userService.updateProfile(id, dto);
assertThat(result.getEmail()).isEqualTo("me@example.com");
}
@@ -564,7 +553,7 @@ class UserServiceTest {
@Test
void adminUpdateUser_setsNullContact_whenContactIsNull() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").contact("old contact").build();
AppUser user = AppUser.builder().id(id).email("admin@example.com").contact("old contact").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -579,7 +568,7 @@ class UserServiceTest {
@Test
void adminUpdateUser_setsNullContact_whenContactIsBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").contact("old").build();
AppUser user = AppUser.builder().id(id).email("admin@example.com").contact("old").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -594,7 +583,7 @@ class UserServiceTest {
@Test
void adminUpdateUser_setsTrimmedContact_whenContactIsNonBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").build();
AppUser user = AppUser.builder().id(id).email("admin@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -609,7 +598,7 @@ class UserServiceTest {
@Test
void adminUpdateUser_doesNotModifyEmail_whenEmailIsNull() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").email("keep@example.com").build();
AppUser user = AppUser.builder().id(id).email("keep@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -624,15 +613,14 @@ class UserServiceTest {
@Test
void adminUpdateUser_allowsSameEmail_whenEmailBelongsToSameUser() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").email("me@example.com").build();
AppUser user = AppUser.builder().id(id).email("me@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("me@example.com")).thenReturn(Optional.of(user)); // same user
when(userRepository.findByEmail("me@example.com")).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("me@example.com");
// Must not throw
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getEmail()).isEqualTo("me@example.com");
}
@@ -641,16 +629,14 @@ class UserServiceTest {
@Test
void createUserOrUpdate_doesNotLoadGroups_whenGroupIdsIsNull() {
// request.getGroupIds() == null → short-circuit (A=false), groupRepository never called
CreateUserRequest req = new CreateUserRequest();
req.setUsername("nullgroups");
req.setEmail("ng@example.com");
req.setInitialPassword("pass");
req.setGroupIds(null); // null → first condition false → short-circuit
req.setGroupIds(null);
when(userRepository.findByUsername("nullgroups")).thenReturn(Optional.empty());
when(userRepository.findByEmail("ng@example.com")).thenReturn(Optional.empty());
when(passwordEncoder.encode("pass")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("nullgroups").build();
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(req);

View File

@@ -53,8 +53,9 @@
"form_placeholder_archive_location": "z.B. Schrank 3, Mappe B",
"form_helper_archive_location": "Wo befindet sich das Originaldokument?",
"login_heading": "Anmelden",
"login_label_username": "Benutzername",
"login_label_email": "E-Mail-Adresse",
"login_label_password": "Passwort",
"login_error_missing_credentials": "Bitte E-Mail-Adresse und Passwort eingeben.",
"login_btn_submit": "Anmelden",
"docs_search_placeholder": "Titel, Personen, Tags durchsuchen…",
"docs_sort_label": "Sortierung",
@@ -164,7 +165,7 @@
"admin_tab_groups": "Gruppen",
"admin_tab_tags": "Schlagworte",
"admin_section_users": "Benutzerverwaltung",
"admin_col_login": "Login",
"admin_col_login": "E-Mail",
"admin_col_groups": "Gruppen",
"admin_col_password": "Passwort",
"admin_multiselect_hint": "Strg+Klick für Auswahl",

View File

@@ -53,8 +53,9 @@
"form_placeholder_archive_location": "e.g. Cabinet 3, Folder B",
"form_helper_archive_location": "Where is the original document stored?",
"login_heading": "Sign in",
"login_label_username": "Username",
"login_label_email": "Email",
"login_label_password": "Password",
"login_error_missing_credentials": "Please enter your email address and password.",
"login_btn_submit": "Sign in",
"docs_search_placeholder": "Search title, people, tags…",
"docs_sort_label": "Sort",
@@ -164,7 +165,7 @@
"admin_tab_groups": "Groups",
"admin_tab_tags": "Tags",
"admin_section_users": "User management",
"admin_col_login": "Login",
"admin_col_login": "Email",
"admin_col_groups": "Groups",
"admin_col_password": "Password",
"admin_multiselect_hint": "Ctrl+Click to select",

View File

@@ -53,8 +53,9 @@
"form_placeholder_archive_location": "p.ej. Armario 3, Carpeta B",
"form_helper_archive_location": "¿Dónde se encuentra el documento original?",
"login_heading": "Iniciar sesión",
"login_label_username": "Usuario",
"login_label_email": "Correo electrónico",
"login_label_password": "Contraseña",
"login_error_missing_credentials": "Por favor, introduzca su correo electrónico y contraseña.",
"login_btn_submit": "Iniciar sesión",
"docs_search_placeholder": "Buscar título, personas, etiquetas…",
"docs_sort_label": "Ordenar",
@@ -164,7 +165,7 @@
"admin_tab_groups": "Grupos",
"admin_tab_tags": "Etiquetas",
"admin_section_users": "Gestión de usuarios",
"admin_col_login": "Login",
"admin_col_login": "Correo electrónico",
"admin_col_groups": "Grupos",
"admin_col_password": "Contraseña",
"admin_multiselect_hint": "Ctrl+Clic para seleccionar",

View File

@@ -5,11 +5,10 @@ declare global {
// Define the User structure matching your Java Entity
interface User {
id: string;
username: string;
firstName?: string;
lastName?: string;
birthDate?: string;
email?: string;
email: string;
contact?: string;
groups: {
id: string;

View File

@@ -33,6 +33,7 @@ export type ErrorCode =
| 'TAG_NOT_FOUND'
| 'TAG_MERGE_SELF'
| 'TAG_MERGE_INVALID_TARGET'
| 'MISSING_CREDENTIALS'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'VALIDATION_ERROR'
@@ -118,6 +119,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_tag_merge_self();
case 'TAG_MERGE_INVALID_TARGET':
return m.error_tag_merge_invalid_target();
case 'MISSING_CREDENTIALS':
return m.login_error_missing_credentials();
case 'UNAUTHORIZED':
return m.error_unauthorized();
case 'FORBIDDEN':

View File

@@ -1253,13 +1253,12 @@ export interface components {
AppUser: {
/** Format: uuid */
id: string;
username: string;
password?: string;
firstName?: string;
lastName?: string;
/** Format: date */
birthDate?: string;
email?: string;
email: string;
contact?: string;
enabled: boolean;
notifyOnReply: boolean;
@@ -1406,8 +1405,7 @@ export interface components {
blockIds?: string[];
};
CreateUserRequest: {
username?: string;
email?: string;
email: string;
initialPassword?: string;
groupIds?: string[];
firstName?: string;

View File

@@ -10,7 +10,7 @@ type Group = {
type User = {
id: string;
username: string;
email: string;
firstName: string | null;
lastName: string | null;
groups: Group[];
@@ -41,7 +41,7 @@ const filtered = $derived(
searchQuery.trim() === ''
? users
: users.filter((u) =>
[u.username, u.firstName, u.lastName]
[u.email, u.firstName, u.lastName]
.filter(Boolean)
.some((v) => v!.toLowerCase().includes(searchQuery.toLowerCase()))
)
@@ -128,7 +128,7 @@ const filtered = $derived(
? 'border-primary bg-primary/10 dark:bg-primary/15'
: 'border-transparent hover:bg-muted'}"
>
<div class="text-sm font-bold text-ink">{user.username}</div>
<div class="text-sm font-bold text-ink">{user.email}</div>
{#if fullName}
<div class="mt-0.5 text-xs text-ink-3">{fullName}</div>
{/if}

View File

@@ -19,7 +19,7 @@ let deleteFormEl = $state<HTMLFormElement | null>(null);
async function handleDelete() {
const confirmed = await confirm({
title: m.admin_user_delete_confirm({ username: data.editUser.username }),
title: m.admin_user_delete_confirm({ username: data.editUser.email }),
destructive: true
});
if (confirmed) deleteFormEl!.requestSubmit();
@@ -49,7 +49,7 @@ $effect(() => {
</svg>
</a>
<h2 class="flex-1 font-sans text-sm font-bold text-ink">
{m.admin_user_edit_heading({ username: data.editUser.username })}
{m.admin_user_edit_heading({ username: data.editUser.email })}
</h2>
<form bind:this={deleteFormEl} method="POST" action="?/delete" use:enhance>
<button

View File

@@ -16,7 +16,6 @@ const groups = [
const makeUser = (overrides = {}) => ({
id: 'u1',
username: 'max',
firstName: 'Max',
lastName: 'Mustermann',
email: 'max@example.com',
@@ -52,9 +51,11 @@ afterEach(cleanup);
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('Admin edit user page rendering', () => {
it('renders the heading with username', async () => {
it('renders the heading with email', async () => {
renderPage({ data: baseData, form: null });
await expect.element(page.getByText(/Benutzer bearbeiten: max/i)).toBeInTheDocument();
await expect
.element(page.getByText(/Benutzer bearbeiten: max@example.com/i))
.toBeInTheDocument();
});
it('pre-fills first name from editUser data', async () => {

View File

@@ -16,12 +16,12 @@ beforeEach(() => vi.clearAllMocks());
describe('admin/users layout load', () => {
it('returns the users list', async () => {
mockApi([
{ id: 'u1', username: 'alice' },
{ id: 'u2', username: 'bob' }
{ id: 'u1', email: 'alice@example.com' },
{ id: 'u2', email: 'bob@example.com' }
]);
const result = await load({ fetch: vi.fn() as unknown as typeof fetch });
expect(result.users).toHaveLength(2);
expect(result.users[0].username).toBe('alice');
expect(result.users[0].email).toBe('alice@example.com');
});
it('returns an empty array when the API returns nothing', async () => {

View File

@@ -12,14 +12,14 @@ afterEach(cleanup);
const users = [
{
id: 'u1',
username: 'reader',
email: 'reader@example.com',
firstName: 'Lea',
lastName: 'Leserin',
groups: [{ id: 'g1', name: 'Leser', permissions: ['READ_ALL'] }]
},
{
id: 'u2',
username: 'admin',
email: 'admin@example.com',
firstName: null,
lastName: null,
groups: [{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }]
@@ -46,10 +46,10 @@ describe('UsersListPanel — header', () => {
});
describe('UsersListPanel — user items', () => {
it('renders each username', async () => {
it('renders each email', async () => {
render(UsersListPanel, { users });
await expect.element(page.getByRole('link', { name: /reader/i })).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: /admin/i })).toBeInTheDocument();
await expect.element(page.getByText('reader@example.com')).toBeInTheDocument();
await expect.element(page.getByText('admin@example.com')).toBeInTheDocument();
});
it('each user links to /admin/users/[id]', async () => {

View File

@@ -24,9 +24,8 @@ export const actions: Actions = {
const birthDateRaw = data.get('birthDate') as string;
const result = await api.POST('/api/users', {
body: {
username: data.get('username') as string,
email: data.get('email') as string,
initialPassword: data.get('password') as string,
email: (data.get('email') as string) || undefined,
groupIds: data.getAll('groupIds') as string[],
firstName: (data.get('firstName') as string) || null,
lastName: (data.get('lastName') as string) || null,

View File

@@ -11,9 +11,10 @@ import { m } from '$lib/paraglide/messages.js';
{m.admin_col_login()}
</span>
<input
type="text"
name="username"
type="email"
name="email"
required
autocomplete="email"
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</label>

View File

@@ -22,9 +22,10 @@ describe('Admin new user page rendering', () => {
await expect.element(page.getByText(/Neuen Benutzer anlegen/i)).toBeInTheDocument();
});
it('renders the login input', async () => {
it('renders the email input', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByRole('textbox', { name: /Login/i })).toBeInTheDocument();
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input).not.toBeNull();
});
it('renders group checkboxes for each available group', async () => {

View File

@@ -16,9 +16,9 @@ const tick = () => new Promise((r) => setTimeout(r, 0));
const makeData = (overrides = {}) => ({
user: {
id: '1',
username: 'max',
firstName: 'Max',
lastName: 'Müller',
email: 'max@example.com',
groups: [],
enabled: true,
createdAt: ''
@@ -39,7 +39,7 @@ describe('Layout user avatar button', () => {
it('shows fallback icon button when names are not set', async () => {
render(Layout, {
data: makeData({
user: { id: '1', username: 'x', groups: [], enabled: true, createdAt: '' }
user: { id: '1', email: 'fallback@example.com', groups: [], enabled: true, createdAt: '' }
}),
children: emptySnippet
});

View File

@@ -5,14 +5,14 @@ import { getErrorMessage } from '$lib/errors';
export const actions = {
login: async ({ request, cookies, fetch }) => {
const data = await request.formData();
const username = data.get('username') as string;
const email = data.get('email') as string;
const password = data.get('password') as string;
if (!username || !password) {
return fail(400, { error: 'Bitte Benutzername und Passwort eingeben.' });
if (!email || !password) {
return fail(400, { error: getErrorMessage('MISSING_CREDENTIALS') });
}
const credentials = btoa(`${username}:${password}`);
const credentials = btoa(`${email}:${password}`);
const authHeader = `Basic ${credentials}`;
// Raw fetch is intentional here: we need to pass an explicit Authorization

View File

@@ -32,16 +32,16 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
<form method="POST" action="?/login" class="space-y-5">
<div>
<label
for="username"
for="email"
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
>{m.login_label_username()}</label
>{m.login_label_email()}</label
>
<input
type="text"
name="username"
id="username"
type="email"
name="email"
id="email"
required
autocomplete="username"
autocomplete="email"
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>

View File

@@ -21,10 +21,10 @@ describe('Login page rendering', () => {
await expect.element(page.getByRole('button', { name: 'Anmelden' })).toBeInTheDocument();
});
it('renders the username input', async () => {
it('renders the email input', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="username"]');
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input).not.toBeNull();
});
@@ -35,10 +35,10 @@ describe('Login page rendering', () => {
expect(input).not.toBeNull();
});
it('username field is required', async () => {
it('email field is required', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="username"]');
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input?.required).toBe(true);
});
@@ -49,6 +49,13 @@ describe('Login page rendering', () => {
expect(input?.required).toBe(true);
});
it('email field has type="email"', async () => {
render(LoginPage, {});
await tick();
const input = document.querySelector<HTMLInputElement>('input[name="email"]');
expect(input?.type).toBe('email');
});
it('password field has type="password"', async () => {
render(LoginPage, {});
await tick();

View File

@@ -6,7 +6,7 @@ let { data } = $props();
const fullName = $derived.by(() => {
const first = data.profileUser.firstName;
const last = data.profileUser.lastName;
return first || last ? [first, last].filter(Boolean).join(' ') : data.profileUser.username;
return first || last ? [first, last].filter(Boolean).join(' ') : data.profileUser.email;
});
const initials = $derived.by(() => {
@@ -70,12 +70,9 @@ const initials = $derived.by(() => {
{/if}
</div>
<!-- Name and username -->
<!-- Name -->
<div class="mb-5 text-center">
<h2 class="font-serif text-xl font-bold text-ink">{fullName}</h2>
{#if data.profileUser.firstName || data.profileUser.lastName}
<p class="mt-0.5 font-sans text-sm text-ink-3">@{data.profileUser.username}</p>
{/if}
</div>
<!-- Field rows -->