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

View File

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

View File

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

View File

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

View File

@@ -100,6 +100,6 @@ public class NotificationController {
// ─── private helpers ────────────────────────────────────────────────────── // ─── private helpers ──────────────────────────────────────────────────────
private AppUser resolveUser(Authentication authentication) { 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) { private UUID resolveUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null; if (authentication == null || !authentication.isAuthenticated()) return null;
try { try {
AppUser user = userService.findByUsername(authentication.getName()); AppUser user = userService.findByEmail(authentication.getName());
return user != null ? user.getId() : null; return user != null ? user.getId() : null;
} catch (Exception e) { } catch (Exception e) {
log.warn("Failed to resolve user ID for authentication: {}", authentication.getName(), 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()) { if (authentication == null || !authentication.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required"); throw DomainException.unauthorized("Authentication required");
} }
AppUser user = userService.findByUsername(authentication.getName()); AppUser user = userService.findByEmail(authentication.getName());
if (user == null) { if (user == null) {
throw DomainException.unauthorized("User not found"); throw DomainException.unauthorized("User not found");
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,24 +29,22 @@ public class CustomUserDetailsService implements UserDetailsService {
private final AppUserRepository userRepository; private final AppUserRepository userRepository;
@Override @Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
AppUser appUser = userRepository.findByUsername(username) AppUser appUser = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User nicht gefunden: " + username)); .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() var authorities = appUser.getGroups().stream()
.flatMap(group -> group.getPermissions().stream()) .flatMap(group -> group.getPermissions().stream())
.peek(p -> { .peek(p -> {
if (!KNOWN_PERMISSIONS.contains(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) .map(SimpleGrantedAuthority::new)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
// Rückgabe des Standard Spring Security User Objekts
return new User( return new User(
appUser.getUsername(), appUser.getEmail(),
appUser.getPassword(), appUser.getPassword(),
appUser.isEnabled(), appUser.isEnabled(),
true, true, true, true, true, true,

View File

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

View File

@@ -18,6 +18,6 @@ public class UserSearchService {
public List<AppUser> search(String query) { public List<AppUser> search(String query) {
if (query == null || query.isBlank()) return List.of(); 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 @Transactional
public AppUser createUserOrUpdate(CreateUserRequest request) { 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<>(); Set<UserGroup> groups = new HashSet<>();
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) { if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
groups.addAll(groupRepository.findAllById(request.getGroupIds())); groups.addAll(groupRepository.findAllById(request.getGroupIds()));
} }
Optional<AppUser> existingUser = userRepository.findByUsername(request.getUsername()); Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
AppUser user; AppUser user;
if (existingUser.isPresent()) { if (existingUser.isPresent()) {
log.info("User exists, updating: {}", request.getUsername()); log.info("User exists, updating: {}", request.getEmail());
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups); user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
} else { } else {
log.info("Creating new user: {}", request.getUsername()); log.info("Creating new user: {}", request.getEmail());
user = AppUser.builder() user = AppUser.builder()
.username(request.getUsername())
.email(request.getEmail()) .email(request.getEmail())
.password(passwordEncoder.encode(request.getInitialPassword())) .password(passwordEncoder.encode(request.getInitialPassword()))
.groups(groups) .groups(groups)
@@ -103,8 +102,8 @@ public class UserService {
} }
}); });
user.setEmail(dto.getEmail().trim()); user.setEmail(dto.getEmail().trim());
} else if (dto.getEmail() != null && dto.getEmail().isBlank()) { } else if (dto.getEmail() != null) {
user.setEmail(null); throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "Email must not be blank");
} }
user.setFirstName(dto.getFirstName()); user.setFirstName(dto.getFirstName());
@@ -126,8 +125,8 @@ public class UserService {
} }
}); });
user.setEmail(dto.getEmail().trim()); user.setEmail(dto.getEmail().trim());
} else if (dto.getEmail() != null && dto.getEmail().isBlank()) { } else if (dto.getEmail() != null) {
user.setEmail(null); throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "Email must not be blank");
} }
user.setFirstName(dto.getFirstName()); user.setFirstName(dto.getFirstName());
@@ -158,9 +157,9 @@ public class UserService {
userRepository.save(user); userRepository.save(user);
} }
public AppUser findByUsername(String username) { public AppUser findByEmail(String email) {
return userRepository.findByUsername(username) return userRepository.findByEmail(email)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for username: " + username)); .orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for email: " + email));
} }
public List<AppUser> getAllUsers() { 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 @Test
@WithMockUser(authorities = "ANNOTATE_ALL") @WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_resolvesNullUserId_whenUserServiceThrows() throws Exception { void createAnnotation_resolvesNullUserId_whenUserServiceThrows() throws Exception {
// findByUsername throws → catch block → resolveUserId returns null // findByEmail throws → catch block → resolveUserId returns null
UUID docId = UUID.randomUUID(); 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() DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1) .id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build(); .x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
@@ -296,9 +296,9 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "ANNOTATE_ALL") @WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_resolvesNullUserId_whenUserServiceReturnsNull() throws Exception { 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(); UUID docId = UUID.randomUUID();
when(userService.findByUsername(any())).thenReturn(null); when(userService.findByEmail(any())).thenReturn(null);
DocumentAnnotation saved = DocumentAnnotation.builder() DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1) .id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build(); .x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when; 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.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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -31,33 +32,32 @@ class UserControllerTest {
@MockitoBean UserService userService; @MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService; @MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/users/me ──────────────────────────────────────────────────── // ─── GET /api/users/me ────────────────────────────────────────────────────────
@Test @Test
void getCurrentUser_returns401_whenUnauthenticated() throws Exception { void getCurrentUser_returns401_whenUnauthenticated() throws Exception {
// authentication == null → returns 401 (covers null/!isAuthenticated branch)
mockMvc.perform(get("/api/users/me")) mockMvc.perform(get("/api/users/me"))
.andExpect(status().isUnauthorized()); .andExpect(status().isUnauthorized());
} }
@Test @Test
@WithMockUser(username = "anna") @WithMockUser(username = "anna@example.com")
void getCurrentUser_returns200_whenAuthenticated() throws Exception { void getCurrentUser_returns200_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); AppUser user = AppUser.builder().id(UUID.randomUUID()).email("anna@example.com").build();
when(userService.findByUsername("anna")).thenReturn(user); when(userService.findByEmail("anna@example.com")).thenReturn(user);
mockMvc.perform(get("/api/users/me")) mockMvc.perform(get("/api/users/me"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("anna")); .andExpect(jsonPath("$.email").value("anna@example.com"));
} }
// ─── GET /api/users/{id} ────────────────────────────────────────────────── // ─── GET /api/users/{id} ──────────────────────────────────────────────────
@Test @Test
@WithMockUser(username = "reader") @WithMockUser(username = "reader@example.com")
void getUser_returns403_whenCallerLacksAdminUserPermission() throws Exception { void getUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
UUID id = UUID.randomUUID(); 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); when(userService.getById(id)).thenReturn(target);
mockMvc.perform(get("/api/users/" + id)) mockMvc.perform(get("/api/users/" + id))
@@ -65,14 +65,43 @@ class UserControllerTest {
} }
@Test @Test
@WithMockUser(username = "admin", authorities = {"ADMIN_USER"}) @WithMockUser(username = "admin@example.com", authorities = {"ADMIN_USER"})
void getUser_returns200_whenCallerHasAdminUserPermission() throws Exception { void getUser_returns200_whenCallerHasAdminUserPermission() throws Exception {
UUID id = UUID.randomUUID(); 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); when(userService.getById(id)).thenReturn(user);
mockMvc.perform(get("/api/users/" + id)) mockMvc.perform(get("/api/users/" + id))
.andExpect(status().isOk()) .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"}) @WithMockUser(authorities = {"READ_ALL"})
void search_returns200_whenAuthenticated() throws Exception { void search_returns200_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(UUID.randomUUID()) 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)); when(userSearchService.search("Hans")).thenReturn(List.of(user));
mockMvc.perform(get("/api/users/search").param("q", "Hans")) mockMvc.perform(get("/api/users/search").param("q", "Hans"))
@@ -83,7 +83,7 @@ class UserSearchControllerTest {
void search_returnsAtMostTenResults() throws Exception { void search_returnsAtMostTenResults() throws Exception {
List<AppUser> elevenUsers = IntStream.range(0, 11) List<AppUser> elevenUsers = IntStream.range(0, 11)
.mapToObj(i -> AppUser.builder().id(UUID.randomUUID()) .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(); .toList();
when(userSearchService.search(anyString())).thenReturn(elevenUsers.subList(0, 10)); when(userSearchService.search(anyString())).thenReturn(elevenUsers.subList(0, 10));

View File

@@ -274,6 +274,34 @@ class MigrationIntegrationTest {
assertThat(rows2).isEqualTo(1); 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 ───────────────────────────────────────────────────────────── // ─── helpers ─────────────────────────────────────────────────────────────
private UUID createPerson(String firstName, String lastName) { private UUID createPerson(String firstName, String lastName) {

View File

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

View File

@@ -43,7 +43,7 @@ class CommentServiceTest {
void postComment_capturesAuthorNameAtWriteTime() { void postComment_capturesAuthorNameAtWriteTime() {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder() 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() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build(); .id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved); when(commentRepository.save(any())).thenReturn(saved);
@@ -56,7 +56,7 @@ class CommentServiceTest {
@Test @Test
void postComment_fallsBackToUsername_whenNamesAreBlank() { void postComment_fallsBackToUsername_whenNamesAreBlank() {
UUID docId = UUID.randomUUID(); 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() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build(); .id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
when(commentRepository.save(any())).thenReturn(saved); when(commentRepository.save(any())).thenReturn(saved);
@@ -70,8 +70,8 @@ class CommentServiceTest {
void postComment_triggersNotifyMentions_whenMentionedUserIdsProvided() { void postComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID mentionedId = UUID.randomUUID(); UUID mentionedId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("M").build(); AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
AppUser mentioned = AppUser.builder().id(mentionedId).username("anna").firstName("Anna").lastName("S").build(); AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build();
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hey @Anna S").build(); .id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hey @Anna S").build();
@@ -89,7 +89,7 @@ class CommentServiceTest {
void replyToComment_throwsNotFound_whenTargetCommentMissing() { void replyToComment_throwsNotFound_whenTargetCommentMissing() {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID commentId = 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()); when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", List.of(), author)) assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", List.of(), author))
@@ -104,7 +104,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID(); UUID rootId = UUID.randomUUID();
UUID replyId = 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() DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build(); .id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
@@ -127,7 +127,7 @@ class CommentServiceTest {
void replyToComment_usesDirectComment_whenReplyingToTopLevel() { void replyToComment_usesDirectComment_whenReplyingToTopLevel() {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID rootId = 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() DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build(); .id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
@@ -147,7 +147,7 @@ class CommentServiceTest {
void replyToComment_triggersNotifyReply_afterSave() { void replyToComment_triggersNotifyReply_afterSave() {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID rootId = 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() DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build(); .id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
@@ -168,8 +168,8 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID(); UUID rootId = UUID.randomUUID();
UUID mentionedId = UUID.randomUUID(); UUID mentionedId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); AppUser author = AppUser.builder().id(UUID.randomUUID()).email("anna@example.com").build();
AppUser mentioned = AppUser.builder().id(mentionedId).username("bob").firstName("Bob").lastName("J").build(); AppUser mentioned = AppUser.builder().id(mentionedId).email("bob@example.com").firstName("Bob").lastName("J").build();
DocumentComment root = DocumentComment.builder() DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build(); .id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
@@ -193,7 +193,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID(); UUID commentId = UUID.randomUUID();
UUID ownerId = 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() DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(ownerId).content("Original").authorName("Hans").build(); .id(commentId).documentId(docId).authorId(ownerId).content("Original").authorName("Hans").build();
@@ -211,7 +211,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID(); UUID commentId = UUID.randomUUID();
UUID authorId = 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); LocalDateTime created = LocalDateTime.now().minusMinutes(5);
DocumentComment comment = DocumentComment.builder() DocumentComment comment = DocumentComment.builder()
@@ -233,7 +233,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID(); UUID commentId = UUID.randomUUID();
UUID ownerId = 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() DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build(); .id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
@@ -251,7 +251,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID(); UUID commentId = UUID.randomUUID();
UUID authorId = 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() DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(authorId).authorName("Hans").content("X").build(); .id(commentId).documentId(docId).authorId(authorId).authorName("Hans").content("X").build();
@@ -306,7 +306,7 @@ class CommentServiceTest {
void replyToComment_handlesNullAuthorId_inExistingReply() { void replyToComment_handlesNullAuthorId_inExistingReply() {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID rootId = 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() DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID()).content("Root").authorName("Root").build(); .id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID()).content("Root").authorName("Root").build();
@@ -331,7 +331,7 @@ class CommentServiceTest {
@Test @Test
void postComment_fallsBackToUsername_whenFirstNameBlankAndLastNameNull() { void postComment_fallsBackToUsername_whenFirstNameBlankAndLastNameNull() {
UUID docId = UUID.randomUUID(); 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(); .firstName(" ").lastName(null).build();
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build(); .id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
@@ -345,7 +345,7 @@ class CommentServiceTest {
@Test @Test
void postComment_fallsBackToUsername_whenFirstNameNullAndLastNameBlank() { void postComment_fallsBackToUsername_whenFirstNameNullAndLastNameBlank() {
UUID docId = UUID.randomUUID(); 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(); .firstName(null).lastName(" ").build();
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build(); .id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
@@ -359,7 +359,7 @@ class CommentServiceTest {
@Test @Test
void postComment_includesOnlyFirstName_whenLastNameIsNull() { void postComment_includesOnlyFirstName_whenLastNameIsNull() {
UUID docId = UUID.randomUUID(); 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(); .firstName("Hans").lastName(null).build();
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans").content("Hi").build(); .id(UUID.randomUUID()).documentId(docId).authorName("Hans").content("Hi").build();
@@ -374,7 +374,7 @@ class CommentServiceTest {
@Test @Test
void postComment_includesOnlyLastName_whenFirstNameIsNull() { void postComment_includesOnlyLastName_whenFirstNameIsNull() {
UUID docId = UUID.randomUUID(); 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(); .firstName(null).lastName("Müller").build();
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Müller").content("Hi").build(); .id(UUID.randomUUID()).documentId(docId).authorName("Müller").content("Hi").build();
@@ -391,7 +391,7 @@ class CommentServiceTest {
@Test @Test
void postComment_doesNotCallUserService_whenMentionedUserIdsIsNull() { void postComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
UUID docId = UUID.randomUUID(); 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(); .firstName("Hans").lastName("M").build();
DocumentComment saved = DocumentComment.builder() DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hi").build(); .id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hi").build();
@@ -409,7 +409,7 @@ class CommentServiceTest {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID(); UUID rootId = UUID.randomUUID();
UUID existingReplyAuthorId = 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() DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID()) .id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID())
@@ -437,7 +437,7 @@ class CommentServiceTest {
void replyToComment_excludesNullAuthorIds_fromParticipantSet() { void replyToComment_excludesNullAuthorIds_fromParticipantSet() {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
UUID rootId = 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 // Root with null authorId
DocumentComment root = DocumentComment.builder() DocumentComment root = DocumentComment.builder()
@@ -480,7 +480,7 @@ class CommentServiceTest {
private AppUser buildAdmin() { private AppUser buildAdmin() {
return AppUser.builder() return AppUser.builder()
.id(UUID.randomUUID()) .id(UUID.randomUUID())
.username("admin") .email("admin@example.com")
.groups(Set.of(UserGroup.builder() .groups(Set.of(UserGroup.builder()
.id(UUID.randomUUID()) .id(UUID.randomUUID())
.name("admins") .name("admins")
@@ -510,7 +510,7 @@ class CommentServiceTest {
void postBlockComment_setsBlockIdOnComment() { void postBlockComment_setsBlockIdOnComment() {
UUID documentId = UUID.randomUUID(); UUID documentId = UUID.randomUUID();
UUID blockId = 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 -> { when(commentRepository.save(any())).thenAnswer(inv -> {
DocumentComment c = inv.getArgument(0); DocumentComment c = inv.getArgument(0);
c.setId(UUID.randomUUID()); 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.AppUser;
import org.raddatz.familienarchiv.model.UserGroup; import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository; 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.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -29,40 +28,40 @@ class CustomUserDetailsServiceTest {
// ─── loadUserByUsername — not found ────────────────────────────────────── // ─── loadUserByUsername — not found ──────────────────────────────────────
@Test @Test
void loadUserByUsername_throwsUsernameNotFoundException_whenUserNotFound() { void loadUserByEmail_throwsUsernameNotFoundException_whenUserNotFound() {
when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty()); when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.loadUserByUsername("ghost")) assertThatThrownBy(() -> service.loadUserByUsername("ghost@example.com"))
.isInstanceOf(UsernameNotFoundException.class) .isInstanceOf(UsernameNotFoundException.class)
.hasMessageContaining("ghost"); .hasMessageContaining("ghost@example.com");
} }
// ─── loadUserByUsername — happy path ───────────────────────────────────── // ─── loadUserByUsername — happy path ─────────────────────────────────────
@Test @Test
void loadUserByUsername_returnsUserDetails_withMappedAuthorities() { void loadUserByEmail_returnsUserDetails_withEmailAsPrincipal() {
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins") UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins")
.permissions(Set.of("READ_ALL", "WRITE_ALL")).build(); .permissions(Set.of("READ_ALL", "WRITE_ALL")).build();
AppUser user = AppUser.builder().id(UUID.randomUUID()) 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(); .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") assertThat(details.getAuthorities()).extracting("authority")
.contains("READ_ALL", "WRITE_ALL"); .contains("READ_ALL", "WRITE_ALL");
} }
@Test @Test
void loadUserByUsername_returnsEmptyAuthorities_whenUserHasNoGroups() { void loadUserByEmail_returnsEmptyAuthorities_whenUserHasNoGroups() {
AppUser user = AppUser.builder().id(UUID.randomUUID()) 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(); .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(); assertThat(details.getAuthorities()).isEmpty();
} }
@@ -70,16 +69,15 @@ class CustomUserDetailsServiceTest {
// ─── loadUserByUsername — unknown permission ────────────────────────────── // ─── loadUserByUsername — unknown permission ──────────────────────────────
@Test @Test
void loadUserByUsername_grantsUnknownPermission_butLogsWarning() { void loadUserByEmail_grantsUnknownPermission_butLogsWarning() {
// Unknown permissions should still be granted (logged as warning, not silently dropped)
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("CustomGroup") UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("CustomGroup")
.permissions(Set.of("UNKNOWN_CUSTOM_PERM")).build(); .permissions(Set.of("UNKNOWN_CUSTOM_PERM")).build();
AppUser user = AppUser.builder().id(UUID.randomUUID()) 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(); .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") assertThat(details.getAuthorities()).extracting("authority")
.contains("UNKNOWN_CUSTOM_PERM"); .contains("UNKNOWN_CUSTOM_PERM");
@@ -88,13 +86,13 @@ class CustomUserDetailsServiceTest {
// ─── loadUserByUsername — disabled user ─────────────────────────────────── // ─── loadUserByUsername — disabled user ───────────────────────────────────
@Test @Test
void loadUserByUsername_returnsDisabledUser_whenUserIsDisabled() { void loadUserByEmail_returnsDisabledUser_whenUserIsDisabled() {
AppUser user = AppUser.builder().id(UUID.randomUUID()) 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(); .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(); assertThat(details.isEnabled()).isFalse();
} }
@@ -102,17 +100,17 @@ class CustomUserDetailsServiceTest {
// ─── loadUserByUsername — multi-group permission merge ──────────────────── // ─── loadUserByUsername — multi-group permission merge ────────────────────
@Test @Test
void loadUserByUsername_mergesPermissionsFromMultipleGroups() { void loadUserByEmail_mergesPermissionsFromMultipleGroups() {
UserGroup g1 = UserGroup.builder().id(UUID.randomUUID()).name("Readers") UserGroup g1 = UserGroup.builder().id(UUID.randomUUID()).name("Readers")
.permissions(Set.of("READ_ALL")).build(); .permissions(Set.of("READ_ALL")).build();
UserGroup g2 = UserGroup.builder().id(UUID.randomUUID()).name("Writers") UserGroup g2 = UserGroup.builder().id(UUID.randomUUID()).name("Writers")
.permissions(Set.of("WRITE_ALL")).build(); .permissions(Set.of("WRITE_ALL")).build();
AppUser user = AppUser.builder().id(UUID.randomUUID()) 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(); .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") assertThat(details.getAuthorities()).extracting("authority")
.containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL"); .containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");

View File

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

View File

@@ -50,13 +50,13 @@ class NotificationServiceTest {
void setUp() { void setUp() {
notificationService = new NotificationService(notificationRepository, userService, documentService, Optional.of(mailSender), sseEmitterRegistry); 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") .firstName("Anna").lastName("Smith").email("a@test.com")
.notifyOnReply(false).notifyOnMention(false).build(); .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") .firstName("Bob").lastName("Jones").email("b@test.com")
.notifyOnReply(false).notifyOnMention(false).build(); .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") .firstName("Clara").lastName("Doe").email("c@test.com")
.notifyOnReply(false).notifyOnMention(false).build(); .notifyOnReply(false).notifyOnMention(false).build();
} }

View File

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

View File

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

View File

@@ -53,8 +53,9 @@
"form_placeholder_archive_location": "z.B. Schrank 3, Mappe B", "form_placeholder_archive_location": "z.B. Schrank 3, Mappe B",
"form_helper_archive_location": "Wo befindet sich das Originaldokument?", "form_helper_archive_location": "Wo befindet sich das Originaldokument?",
"login_heading": "Anmelden", "login_heading": "Anmelden",
"login_label_username": "Benutzername", "login_label_email": "E-Mail-Adresse",
"login_label_password": "Passwort", "login_label_password": "Passwort",
"login_error_missing_credentials": "Bitte E-Mail-Adresse und Passwort eingeben.",
"login_btn_submit": "Anmelden", "login_btn_submit": "Anmelden",
"docs_search_placeholder": "Titel, Personen, Tags durchsuchen…", "docs_search_placeholder": "Titel, Personen, Tags durchsuchen…",
"docs_sort_label": "Sortierung", "docs_sort_label": "Sortierung",
@@ -164,7 +165,7 @@
"admin_tab_groups": "Gruppen", "admin_tab_groups": "Gruppen",
"admin_tab_tags": "Schlagworte", "admin_tab_tags": "Schlagworte",
"admin_section_users": "Benutzerverwaltung", "admin_section_users": "Benutzerverwaltung",
"admin_col_login": "Login", "admin_col_login": "E-Mail",
"admin_col_groups": "Gruppen", "admin_col_groups": "Gruppen",
"admin_col_password": "Passwort", "admin_col_password": "Passwort",
"admin_multiselect_hint": "Strg+Klick für Auswahl", "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_placeholder_archive_location": "e.g. Cabinet 3, Folder B",
"form_helper_archive_location": "Where is the original document stored?", "form_helper_archive_location": "Where is the original document stored?",
"login_heading": "Sign in", "login_heading": "Sign in",
"login_label_username": "Username", "login_label_email": "Email",
"login_label_password": "Password", "login_label_password": "Password",
"login_error_missing_credentials": "Please enter your email address and password.",
"login_btn_submit": "Sign in", "login_btn_submit": "Sign in",
"docs_search_placeholder": "Search title, people, tags…", "docs_search_placeholder": "Search title, people, tags…",
"docs_sort_label": "Sort", "docs_sort_label": "Sort",
@@ -164,7 +165,7 @@
"admin_tab_groups": "Groups", "admin_tab_groups": "Groups",
"admin_tab_tags": "Tags", "admin_tab_tags": "Tags",
"admin_section_users": "User management", "admin_section_users": "User management",
"admin_col_login": "Login", "admin_col_login": "Email",
"admin_col_groups": "Groups", "admin_col_groups": "Groups",
"admin_col_password": "Password", "admin_col_password": "Password",
"admin_multiselect_hint": "Ctrl+Click to select", "admin_multiselect_hint": "Ctrl+Click to select",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ const groups = [
const makeUser = (overrides = {}) => ({ const makeUser = (overrides = {}) => ({
id: 'u1', id: 'u1',
username: 'max',
firstName: 'Max', firstName: 'Max',
lastName: 'Mustermann', lastName: 'Mustermann',
email: 'max@example.com', email: 'max@example.com',
@@ -52,9 +51,11 @@ afterEach(cleanup);
// ─── Rendering ──────────────────────────────────────────────────────────────── // ─── Rendering ────────────────────────────────────────────────────────────────
describe('Admin edit user page 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 }); 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 () => { it('pre-fills first name from editUser data', async () => {

View File

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

View File

@@ -12,14 +12,14 @@ afterEach(cleanup);
const users = [ const users = [
{ {
id: 'u1', id: 'u1',
username: 'reader', email: 'reader@example.com',
firstName: 'Lea', firstName: 'Lea',
lastName: 'Leserin', lastName: 'Leserin',
groups: [{ id: 'g1', name: 'Leser', permissions: ['READ_ALL'] }] groups: [{ id: 'g1', name: 'Leser', permissions: ['READ_ALL'] }]
}, },
{ {
id: 'u2', id: 'u2',
username: 'admin', email: 'admin@example.com',
firstName: null, firstName: null,
lastName: null, lastName: null,
groups: [{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }] groups: [{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }]
@@ -46,10 +46,10 @@ describe('UsersListPanel — header', () => {
}); });
describe('UsersListPanel — user items', () => { describe('UsersListPanel — user items', () => {
it('renders each username', async () => { it('renders each email', async () => {
render(UsersListPanel, { users }); render(UsersListPanel, { users });
await expect.element(page.getByRole('link', { name: /reader/i })).toBeInTheDocument(); await expect.element(page.getByText('reader@example.com')).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: /admin/i })).toBeInTheDocument(); await expect.element(page.getByText('admin@example.com')).toBeInTheDocument();
}); });
it('each user links to /admin/users/[id]', async () => { 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 birthDateRaw = data.get('birthDate') as string;
const result = await api.POST('/api/users', { const result = await api.POST('/api/users', {
body: { body: {
username: data.get('username') as string, email: data.get('email') as string,
initialPassword: data.get('password') as string, initialPassword: data.get('password') as string,
email: (data.get('email') as string) || undefined,
groupIds: data.getAll('groupIds') as string[], groupIds: data.getAll('groupIds') as string[],
firstName: (data.get('firstName') as string) || null, firstName: (data.get('firstName') as string) || null,
lastName: (data.get('lastName') 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()} {m.admin_col_login()}
</span> </span>
<input <input
type="text" type="email"
name="username" name="email"
required 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" 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> </label>

View File

@@ -22,9 +22,10 @@ describe('Admin new user page rendering', () => {
await expect.element(page.getByText(/Neuen Benutzer anlegen/i)).toBeInTheDocument(); 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 }); 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 () => { 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 = {}) => ({ const makeData = (overrides = {}) => ({
user: { user: {
id: '1', id: '1',
username: 'max',
firstName: 'Max', firstName: 'Max',
lastName: 'Müller', lastName: 'Müller',
email: 'max@example.com',
groups: [], groups: [],
enabled: true, enabled: true,
createdAt: '' createdAt: ''
@@ -39,7 +39,7 @@ describe('Layout user avatar button', () => {
it('shows fallback icon button when names are not set', async () => { it('shows fallback icon button when names are not set', async () => {
render(Layout, { render(Layout, {
data: makeData({ data: makeData({
user: { id: '1', username: 'x', groups: [], enabled: true, createdAt: '' } user: { id: '1', email: 'fallback@example.com', groups: [], enabled: true, createdAt: '' }
}), }),
children: emptySnippet children: emptySnippet
}); });

View File

@@ -5,14 +5,14 @@ import { getErrorMessage } from '$lib/errors';
export const actions = { export const actions = {
login: async ({ request, cookies, fetch }) => { login: async ({ request, cookies, fetch }) => {
const data = await request.formData(); 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; const password = data.get('password') as string;
if (!username || !password) { if (!email || !password) {
return fail(400, { error: 'Bitte Benutzername und Passwort eingeben.' }); return fail(400, { error: getErrorMessage('MISSING_CREDENTIALS') });
} }
const credentials = btoa(`${username}:${password}`); const credentials = btoa(`${email}:${password}`);
const authHeader = `Basic ${credentials}`; const authHeader = `Basic ${credentials}`;
// Raw fetch is intentional here: we need to pass an explicit Authorization // 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"> <form method="POST" action="?/login" class="space-y-5">
<div> <div>
<label <label
for="username" for="email"
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase" 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 <input
type="text" type="email"
name="username" name="email"
id="username" id="email"
required 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" 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> </div>

View File

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

View File

@@ -6,7 +6,7 @@ let { data } = $props();
const fullName = $derived.by(() => { const fullName = $derived.by(() => {
const first = data.profileUser.firstName; const first = data.profileUser.firstName;
const last = data.profileUser.lastName; 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(() => { const initials = $derived.by(() => {
@@ -70,12 +70,9 @@ const initials = $derived.by(() => {
{/if} {/if}
</div> </div>
<!-- Name and username --> <!-- Name -->
<div class="mb-5 text-center"> <div class="mb-5 text-center">
<h2 class="font-serif text-xl font-bold text-ink">{fullName}</h2> <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> </div>
<!-- Field rows --> <!-- Field rows -->