Compare commits

...

32 Commits

Author SHA1 Message Date
Marcel
dd1bd837ad test(audit): integration test — create + delete user produces ordered audit entries
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m11s
CI / Backend Unit Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 3m13s
CI / OCR Service Tests (pull_request) Successful in 33s
CI / Backend Unit Tests (pull_request) Failing after 3m6s
Creates a real actor user first (needed for audit_log FK constraint),
then creates and deletes a target user, asserts USER_DELETED is newest
and USER_CREATED is second via findRecentUserManagementEvents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:12:03 +02:00
Marcel
d03521724c feat(audit): add findRecentUserManagementEvents query method
Adds findRecentByKinds JPQL query to AuditLogQueryRepository and
findRecentUserManagementEvents(int limit) to AuditLogQueryService,
returning the N most recent USER_CREATED/USER_DELETED/GROUP_MEMBERSHIP_CHANGED
events ordered newest-first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:12:03 +02:00
Marcel
3fac8b4e7f feat(audit): emit GROUP_MEMBERSHIP_CHANGED when admin updates user groups
Adds actorId param to adminUpdateUser(), captures beforeGroups before
mutation, computes added/removed group names, emits logAfterCommit only
when the group set actually changes. Payload contains group names, not
permission strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:12:03 +02:00
Marcel
81387b2f1b feat(audit): emit USER_DELETED when admin removes a user
Adds actorId param to deleteUser(), captures email before deletion,
emits logAfterCommit(USER_DELETED) with userId+email in payload.
Updates UserController to resolve and pass actorId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:12:03 +02:00
Marcel
9cd48007a0 feat(audit): emit USER_CREATED when admin creates a new user
Adds USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED to AuditKind.
Injects AuditService into UserService; changes createUserOrUpdate to
accept actorId and emits logAfterCommit(USER_CREATED) only on the
new-user branch. Updates UserController to resolve and pass actorId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:12:03 +02:00
Marcel
1f7509413d refactor(persons): extract inputCls/labelCls and PersonFormData type
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m26s
CI / OCR Service Tests (push) Successful in 1m4s
CI / Backend Unit Tests (push) Failing after 3m18s
CI / Unit & Component Tests (pull_request) Failing after 3m26s
CI / OCR Service Tests (pull_request) Successful in 42s
CI / Backend Unit Tests (pull_request) Failing after 3m2s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 12:34:49 +02:00
Marcel
da1270c419 refactor(persons): rename page.server.test.ts to normalizePersonType.test.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 12:30:07 +02:00
Marcel
409a8fc8f2 test(persons): replace fragile CSS class tests with aria-checked behavior tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 12:27:29 +02:00
Marcel
c154dcd47e a11y(persons): add aria-label to PersonTypeSelector radiogroup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 12:25:03 +02:00
Marcel
f5eb14a76d test(persons): assert error code in createPerson_returns400_whenPersonTypeIsSkip
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m16s
CI / OCR Service Tests (push) Successful in 49s
CI / Backend Unit Tests (push) Failing after 3m23s
CI / Unit & Component Tests (pull_request) Failing after 3m25s
CI / OCR Service Tests (pull_request) Successful in 57s
CI / Backend Unit Tests (pull_request) Failing after 2m56s
Adds jsonPath("$.code").value("INVALID_PERSON_TYPE") to verify the full
error response shape, not just the HTTP status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:32:13 +02:00
Marcel
00af97653d refactor(persons): remove what-comment from PersonCard title block
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:29:46 +02:00
Marcel
f9a982db43 fix(persons): localize validation error messages via Paraglide i18n
validatePersonFields now returns a PersonValidationKey instead of a
hardcoded German string. resolveValidationMessage() translates the key
through Paraglide so English and Spanish locale users no longer see
German error text. Adds validation_last_name_required and
validation_first_name_required to all three message files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:28:28 +02:00
Marcel
8e1733abbf refactor(persons): centralise PersonType, PERSON_TYPES and normalizePersonType in person-validation
Removes four independent PersonType type declarations and the duplicated
TYPES/PERSON_TYPES arrays. normalizePersonType moves from the edit route
module into the shared lib so page.server.test.ts no longer imports from a
route. Both server actions now use normalizePersonType for personType
extraction instead of an inline type cast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:22:52 +02:00
Marcel
842ab28f59 fix(persons): keyboard navigation now updates PersonTypeSelector reactive state
radioGroupNav now accepts an onChange callback; PersonTypeSelector passes
select() as the callback so ArrowLeft/Right navigation updates the hidden
input value. aria-live region starts empty and announces only on user
interaction (fixes initial page-load announcement).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:17:47 +02:00
Marcel
0c47c22185 test(persons): extract validatePersonFields and cover validation branches
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m28s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 2m57s
CI / Unit & Component Tests (pull_request) Failing after 3m12s
CI / OCR Service Tests (pull_request) Successful in 50s
CI / Backend Unit Tests (pull_request) Failing after 3m3s
- New src/lib/person-validation.ts exports validatePersonFields (pure function)
- 8 unit tests covering: valid PERSON, lastName missing/undefined,
  firstName missing/undefined for PERSON, non-PERSON types without firstName
- Both edit and new-person server actions now call the shared helper instead
  of inline if-chains, making the logic testable and non-duplicated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:04 +02:00
Marcel
d97cd06f70 refactor(persons): export normalizePersonType from edit server module
Tests now import from production code instead of a local copy, giving real
regression protection if the inline logic is changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
b41405cb4b fix(persons): trim title server-side and add SKIP controller test
- PersonController trims title (both create + update) matching the existing firstName/lastName trim pattern
- PersonControllerTest: verifies title is trimmed before service call (ArgumentCaptor)
- PersonControllerTest: verifies createPerson returns 400 when personType is SKIP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
c9e9300216 fix(persons): use semantic color tokens in PersonTypeSelector for dark mode
Replaces hardcoded brand-navy/brand-sand/white classes with semantic
tokens (bg-primary/text-primary-fg, bg-surface/text-ink, border-line,
ring-focus-ring) so the segmented control adapts correctly in dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
92b082ec62 feat(persons): show title in small-caps above display name in PersonCard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
8770ca874b feat(persons): add type selector + title + conditional fields to new-person form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
e54240ea1b feat(persons): extract personType + title in edit action; relax firstName for non-PERSON
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
437144174c feat(persons): add type selector + title + conditional fields to edit form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 09:57:03 +02:00
Marcel
fe830ad64b feat(persons): add PersonTypeSelector segmented control component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:37:13 +02:00
Marcel
8f75552503 feat(i18n): add form_label_person_type, form_label_name, a11y_type_changed keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:31:11 +02:00
Marcel
e7573bbeda feat(persons): normalize SKIP→UNKNOWN in edit-route load function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:28:10 +02:00
Marcel
bf31380141 feat(persons): add radioGroupNav action for keyboard navigation in type selector
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:17:06 +02:00
Marcel
58b3dabea2 feat(persons): relax firstName requirement for non-PERSON types in controller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:07:55 +02:00
Marcel
60a278ad8e feat(persons): updatePerson rejects SKIP with INVALID_PERSON_TYPE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 21:02:39 +02:00
Marcel
39f722fec0 feat(persons): updatePerson persists personType from DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:59:58 +02:00
Marcel
fef021bf51 feat(persons): createPerson(DTO) rejects SKIP with INVALID_PERSON_TYPE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:57:15 +02:00
Marcel
6c117611b8 feat(persons): add personType to PersonUpdateDTO and wire into createPerson
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:54:37 +02:00
Marcel
aac8250af0 feat(persons): add INVALID_PERSON_TYPE error code with i18n translations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:51:00 +02:00
30 changed files with 1116 additions and 206 deletions

View File

@@ -26,7 +26,16 @@ public enum AuditKind {
COMMENT_ADDED,
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
MENTION_CREATED;
MENTION_CREATED,
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
USER_CREATED,
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
USER_DELETED,
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
GROUP_MEMBERSHIP_CHANGED;
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,

View File

@@ -197,4 +197,8 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
ORDER BY ranked.document_id, ranked.rn
""", nativeQuery = true)
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
@Query("SELECT a FROM AuditLog a WHERE a.kind IN :kinds ORDER BY a.happenedAt DESC LIMIT :limit")
List<AuditLog> findRecentByKinds(@Param("kinds") Collection<AuditKind> kinds,
@Param("limit") int limit);
}

View File

@@ -6,6 +6,10 @@ import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.*;
import static org.raddatz.familienarchiv.audit.AuditKind.GROUP_MEMBERSHIP_CHANGED;
import static org.raddatz.familienarchiv.audit.AuditKind.USER_CREATED;
import static org.raddatz.familienarchiv.audit.AuditKind.USER_DELETED;
@Service
@RequiredArgsConstructor
public class AuditLogQueryService {
@@ -51,6 +55,10 @@ public class AuditLogQueryService {
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
}
public List<AuditLog> findRecentUserManagementEvents(int limit) {
return queryRepository.findRecentByKinds(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), limit);
}
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
for (ContributorRow row : rows) {

View File

@@ -63,27 +63,33 @@ public class PersonController {
@PostMapping
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
dto.setFirstName(dto.getFirstName().trim());
validatePersonNames(dto);
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
return ResponseEntity.ok(personService.createPerson(dto));
}
@PutMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
dto.setFirstName(dto.getFirstName().trim());
validatePersonNames(dto);
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
return ResponseEntity.ok(personService.updatePerson(id, dto));
}
private void validatePersonNames(PersonUpdateDTO dto) {
if (dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nachname ist Pflichtfeld");
}
if (dto.getPersonType() == org.raddatz.familienarchiv.model.PersonType.PERSON
&& (dto.getFirstName() == null || dto.getFirstName().isBlank())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vorname ist Pflichtfeld");
}
}
@PostMapping("/{id}/merge")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)

View File

@@ -78,23 +78,29 @@ public class UserController {
@PostMapping("/users")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.createUserOrUpdate(request));
public ResponseEntity<AppUser> createUser(Authentication authentication,
@Valid @RequestBody CreateUserRequest request) {
AppUser actor = userService.findByEmail(authentication.getName());
return ResponseEntity.ok(userService.createUserOrUpdate(actor.getId(), request));
}
@PutMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
@PathVariable UUID id,
@RequestBody AdminUpdateUserRequest dto) {
AppUser updated = userService.adminUpdateUser(id, dto);
AppUser actor = userService.findByEmail(authentication.getName());
AppUser updated = userService.adminUpdateUser(actor.getId(), id, dto);
updated.setPassword(null);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
userService.deleteUser(id);
public ResponseEntity<Void> deleteUser(Authentication authentication,
@PathVariable UUID id) {
AppUser actor = userService.findByEmail(authentication.getName());
userService.deleteUser(actor.getId(), id);
return ResponseEntity.ok().build();
}

View File

@@ -1,10 +1,14 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.raddatz.familienarchiv.model.PersonType;
@Data
public class PersonUpdateDTO {
@NotNull
private PersonType personType;
@Size(max = 50)
private String title;
@Size(max = 100)

View File

@@ -13,6 +13,8 @@ public enum ErrorCode {
PERSON_NOT_FOUND,
/** A person name alias with the given ID does not exist. 404 */
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
// --- Documents ---
/** A document with the given ID does not exist. 404 */

View File

@@ -109,8 +109,12 @@ public class PersonService {
@Transactional
public Person createPerson(PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
}
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = Person.builder()
.personType(dto.getPersonType())
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
.firstName(dto.getFirstName())
.lastName(dto.getLastName())
@@ -136,9 +140,13 @@ public class PersonService {
@Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
}
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
person.setPersonType(dto.getPersonType());
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
person.setFirstName(dto.getFirstName());
person.setLastName(dto.getLastName());

View File

@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
@@ -21,10 +23,13 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static java.util.stream.Collectors.toSet;
@Service
@RequiredArgsConstructor
@Slf4j
@@ -33,9 +38,10 @@ public class UserService {
private final AppUserRepository userRepository;
private final UserGroupRepository groupRepository;
private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
@Transactional
public AppUser createUserOrUpdate(CreateUserRequest request) {
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
log.info("Creating or updating user: {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>();
@@ -45,10 +51,12 @@ public class UserService {
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
AppUser user;
boolean isNew;
if (existingUser.isPresent()) {
log.info("User exists, updating: {}", request.getEmail());
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
isNew = false;
} else {
log.info("Creating new user: {}", request.getEmail());
user = AppUser.builder()
@@ -61,9 +69,15 @@ public class UserService {
.contact(request.getContact())
.enabled(true)
.build();
isNew = true;
}
return userRepository.save(user);
AppUser saved = userRepository.save(user);
if (isNew) {
auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null,
Map.of("userId", saved.getId().toString(), "email", saved.getEmail()));
}
return saved;
}
@Transactional
@@ -94,10 +108,13 @@ public class UserService {
}
@Transactional
public void deleteUser(UUID userId) {
public void deleteUser(UUID actorId, UUID userId) {
AppUser user = userRepository.findById(userId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
String email = user.getEmail();
userRepository.delete(user);
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
Map.of("userId", userId.toString(), "email", email));
}
public AppUser getById(UUID id) {
@@ -141,7 +158,7 @@ public class UserService {
}
@Transactional
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
AppUser user = getById(id);
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
@@ -166,8 +183,22 @@ public class UserService {
}
if (dto.getGroupIds() != null) {
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
user.setGroups(groups);
Set<UUID> beforeIds = user.getGroups().stream().map(UserGroup::getId).collect(toSet());
Set<UserGroup> beforeGroups = new HashSet<>(user.getGroups());
Set<UserGroup> newGroups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
user.setGroups(newGroups);
Set<UUID> afterIds = newGroups.stream().map(UserGroup::getId).collect(toSet());
if (!beforeIds.equals(afterIds)) {
List<String> added = newGroups.stream()
.filter(g -> !beforeIds.contains(g.getId()))
.map(UserGroup::getName).toList();
List<String> removed = beforeGroups.stream()
.filter(g -> !afterIds.contains(g.getId()))
.map(UserGroup::getName).toList();
auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null,
Map.of("userId", id.toString(), "email", user.getEmail(),
"addedGroups", added, "removedGroups", removed));
}
}
return userRepository.save(user);

View File

@@ -6,12 +6,17 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.model.AppUser;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -47,4 +52,20 @@ class AuditLogQueryServiceTest {
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
}
@Test
void findRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKinds() {
AuditLog entry = AuditLog.builder().id(UUID.randomUUID()).kind(AuditKind.USER_CREATED).build();
when(queryRepository.findRecentByKinds(anyCollection(), eq(5))).thenReturn(List.of(entry));
List<AuditLog> result = auditLogQueryService.findRecentUserManagementEvents(5);
assertThat(result).containsExactly(entry);
verify(queryRepository).findRecentByKinds(
argThat((Collection<AuditKind> kinds) ->
kinds.contains(AuditKind.USER_CREATED) &&
kinds.contains(AuditKind.USER_DELETED) &&
kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)),
eq(5));
}
}

View File

@@ -0,0 +1,75 @@
package org.raddatz.familienarchiv.audit;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
import java.util.UUID;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class UserManagementAuditIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired UserService userService;
@Autowired AppUserRepository userRepository;
@Autowired AuditLogRepository auditLogRepository;
@Autowired AuditLogQueryService auditLogQueryService;
@Autowired TransactionTemplate transactionTemplate;
@Test
void createAndDeleteUser_producesOrderedAuditEntries() {
// Create the actor (admin) user directly — bypasses audit logging so no FK issue
CreateUserRequest adminReq = new CreateUserRequest();
adminReq.setEmail("admin@test.example.com");
adminReq.setInitialPassword("admin-secret");
AppUser actor = transactionTemplate.execute(status ->
userService.createUserOrUpdate(null, adminReq));
UUID actorId = actor.getId();
// The admin creation is logged with null actorId — clear to start with a clean slate
await().atMost(5, SECONDS).until(() -> auditLogRepository.count() > 0);
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
// Create the target user — should emit USER_CREATED
CreateUserRequest req = new CreateUserRequest();
req.setEmail("audit-test@example.com");
req.setInitialPassword("secret");
transactionTemplate.execute(status -> {
userService.createUserOrUpdate(actorId, req);
return null;
});
await().atMost(5, SECONDS).until(() -> auditLogRepository.count() > 0);
// Delete the target user — should emit USER_DELETED
AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow();
transactionTemplate.execute(status -> {
userService.deleteUser(actorId, created.getId());
return null;
});
await().atMost(5, SECONDS).until(() -> auditLogRepository.count() >= 2);
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
assertThat(events).hasSize(2);
assertThat(events.get(0).getKind()).isEqualTo(AuditKind.USER_DELETED);
assertThat(events.get(1).getKind()).isEqualTo(AuditKind.USER_CREATED);
}
}

View File

@@ -1,6 +1,9 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonNameAlias;
@@ -25,6 +28,7 @@ import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
@@ -183,19 +187,19 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\"}"))
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -204,7 +208,7 @@ class PersonControllerTest {
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\"}"))
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -213,7 +217,7 @@ class PersonControllerTest {
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -225,11 +229,53 @@ class PersonControllerTest {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Hans"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns200_forInstitution_withoutFirstName() throws Exception {
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lastName").value("Verlag GmbH"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_trimsTitle_beforePersisting() throws Exception {
ArgumentCaptor<org.raddatz.familienarchiv.dto.PersonUpdateDTO> captor =
ArgumentCaptor.forClass(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class);
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personService.createPerson(captor.capture())).thenReturn(saved);
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk());
assertThat(captor.getValue().getTitle()).isEqualTo("Prof.");
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenPersonTypeIsSkip() throws Exception {
when(personService.createPerson(any())).thenThrow(
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"));
}
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
@Test
@@ -242,10 +288,10 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -254,7 +300,7 @@ class PersonControllerTest {
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\"}"))
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -267,7 +313,7 @@ class PersonControllerTest {
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lastName").value("Müller"));
}
@@ -317,11 +363,10 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
// firstName valid, lastName blank → second || operand = true → 400
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -339,7 +384,7 @@ class PersonControllerTest {
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
"\"notes\":\"Some notes\"}"))
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Maria"))
.andExpect(jsonPath("$.alias").value("Oma Maria"))
@@ -355,7 +400,7 @@ class PersonControllerTest {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -366,7 +411,7 @@ class PersonControllerTest {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isBadRequest());
}
@@ -377,7 +422,7 @@ class PersonControllerTest {
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isForbidden());
}
@@ -386,7 +431,7 @@ class PersonControllerTest {
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.andExpect(status().isForbidden());
}

View File

@@ -114,6 +114,43 @@ class PersonServiceTest {
assertThat(result.getAlias()).isEqualTo("Hans Müller");
}
// ─── personType + title in createPerson(PersonUpdateDTO) ─────────────────
@Test
void createPerson_dto_persistsPersonType() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Walter"); dto.setLastName("de Gruyter"); dto.setPersonType(PersonType.INSTITUTION);
Person result = personService.createPerson(dto);
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
}
@Test
void createPerson_dto_throwsInvalidPersonType_whenSkip() {
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.SKIP);
assertThatThrownBy(() -> personService.createPerson(dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(400);
}
@Test
void createPerson_dto_persistsTitle() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Dr."); dto.setLastName("Müller"); dto.setTitle("Prof."); dto.setPersonType(PersonType.PERSON);
Person result = personService.createPerson(dto);
assertThat(result.getTitle()).isEqualTo("Prof.");
}
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
@Test
@@ -145,6 +182,36 @@ class PersonServiceTest {
.isEqualTo(400);
}
// ─── updatePerson (personType) ───────────────────────────────────────────
@Test
void updatePerson_throwsInvalidPersonType_whenSkip() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.SKIP);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(400);
}
@Test
void updatePerson_persistsPersonType() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").personType(PersonType.PERSON).build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.INSTITUTION);
Person result = personService.updatePerson(id, dto);
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
}
// ─── updatePerson (alias) ─────────────────────────────────────────────────
@Test

View File

@@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
@@ -34,6 +37,7 @@ class UserServiceTest {
@Mock AppUserRepository userRepository;
@Mock UserGroupRepository groupRepository;
@Mock PasswordEncoder passwordEncoder;
@Mock AuditService auditService;
@InjectMocks UserService userService;
// ─── findByEmail ──────────────────────────────────────────────────────────
@@ -61,7 +65,7 @@ class UserServiceTest {
UUID id = UUID.randomUUID();
when(userRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.deleteUser(id))
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id))
.isInstanceOf(DomainException.class);
}
@@ -71,7 +75,7 @@ class UserServiceTest {
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
userService.deleteUser(id);
userService.deleteUser(UUID.randomUUID(), id);
verify(userRepository).delete(user);
}
@@ -90,7 +94,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(req);
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
assertThat(result).isEqualTo(saved);
verify(userRepository).save(any());
@@ -108,7 +112,7 @@ class UserServiceTest {
when(passwordEncoder.encode(any())).thenReturn("encoded");
when(userRepository.save(any())).thenReturn(existing);
userService.createUserOrUpdate(req);
userService.createUserOrUpdate(UUID.randomUUID(), req);
verify(userRepository, times(1)).save(existing);
}
@@ -229,7 +233,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getFirstName()).isEqualTo("Ada");
assertThat(result.getLastName()).isEqualTo("Lovelace");
@@ -246,7 +250,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getGroups()).containsExactly(adminGroup);
}
@@ -264,7 +268,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(newGroup.getId()));
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getGroups()).containsExactly(newGroup);
}
@@ -281,7 +285,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of());
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getGroups()).isEmpty();
}
@@ -313,7 +317,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(req);
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
assertThat(result).isEqualTo(saved);
verify(groupRepository).findAllById(List.of(group.getId()));
@@ -378,7 +382,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword("newSecret");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getPassword()).isEqualTo("newHashed");
}
@@ -393,7 +397,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword(" ");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getPassword()).isEqualTo("original");
verify(passwordEncoder, never()).encode(any());
@@ -408,7 +412,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(" ");
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("blank");
}
@@ -425,7 +429,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("taken@example.com");
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("E-Mail");
}
@@ -497,7 +501,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(req);
userService.createUserOrUpdate(UUID.randomUUID(), req);
verify(groupRepository, never()).findAllById(any());
}
@@ -561,7 +565,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(null);
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getContact()).isNull();
}
@@ -576,7 +580,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" ");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getContact()).isNull();
}
@@ -591,7 +595,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" phone: 555 ");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getContact()).isEqualTo("phone: 555");
}
@@ -606,7 +610,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(null);
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getEmail()).isEqualTo("keep@example.com");
}
@@ -622,7 +626,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("me@example.com");
AppUser result = userService.adminUpdateUser(id, dto);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
assertThat(result.getEmail()).isEqualTo("me@example.com");
}
@@ -640,7 +644,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(req);
userService.createUserOrUpdate(UUID.randomUUID(), req);
verify(groupRepository, never()).findAllById(any());
}
@@ -699,6 +703,140 @@ class UserServiceTest {
assertThat(result).containsExactly(g);
}
// ─── audit: GROUP_MEMBERSHIP_CHANGED ─────────────────────────────────────
@Test
void adminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").permissions(Set.of("READ_ALL")).build();
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").permissions(Set.of("WRITE_ALL")).build();
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(oldGroup)).build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(newGroup.getId()));
userService.adminUpdateUser(actorId, userId, dto);
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
verify(auditService).logAfterCommit(
org.mockito.ArgumentMatchers.eq(AuditKind.GROUP_MEMBERSHIP_CHANGED),
org.mockito.ArgumentMatchers.eq(actorId),
org.mockito.ArgumentMatchers.isNull(),
payloadCaptor.capture());
java.util.Map<String, Object> payload = payloadCaptor.getValue();
assertThat(payload).containsEntry("email", "u@example.com");
assertThat((java.util.List<String>) payload.get("addedGroups")).containsExactly("Editors");
assertThat((java.util.List<String>) payload.get("removedGroups")).containsExactly("Viewers");
}
@Test
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchanged() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(group.getId()));
userService.adminUpdateUser(actorId, userId, dto);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
@Test
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNull() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
// groupIds not set → null
userService.adminUpdateUser(actorId, userId, dto);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
// ─── audit: USER_DELETED ──────────────────────────────────────────────────
@Test
void deleteUser_logsUserDeleted_withEmailInPayload() {
UUID actorId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
AppUser user = AppUser.builder().id(userId).email("gone@example.com").build();
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
userService.deleteUser(actorId, userId);
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
verify(auditService).logAfterCommit(
org.mockito.ArgumentMatchers.eq(AuditKind.USER_DELETED),
org.mockito.ArgumentMatchers.eq(actorId),
org.mockito.ArgumentMatchers.isNull(),
payloadCaptor.capture());
assertThat(payloadCaptor.getValue()).containsEntry("email", "gone@example.com");
assertThat(payloadCaptor.getValue()).containsKey("userId");
}
// ─── audit: USER_CREATED ──────────────────────────────────────────────────
@Test
void createUserOrUpdate_logsUserCreated_whenUserIsNew() {
UUID actorId = UUID.randomUUID();
CreateUserRequest req = new CreateUserRequest();
req.setEmail("new@example.com");
req.setInitialPassword("secret");
req.setGroupIds(List.of());
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
when(passwordEncoder.encode("secret")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(actorId, req);
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
verify(auditService).logAfterCommit(
org.mockito.ArgumentMatchers.eq(AuditKind.USER_CREATED),
org.mockito.ArgumentMatchers.eq(actorId),
org.mockito.ArgumentMatchers.isNull(),
payloadCaptor.capture());
assertThat(payloadCaptor.getValue()).containsKey("userId");
assertThat(payloadCaptor.getValue()).containsEntry("email", "new@example.com");
}
@Test
void createUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists() {
UUID actorId = UUID.randomUUID();
CreateUserRequest req = new CreateUserRequest();
req.setEmail("existing@example.com");
req.setInitialPassword("pass");
req.setGroupIds(List.of());
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build();
when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing));
when(passwordEncoder.encode(any())).thenReturn("encoded");
when(userRepository.save(any())).thenReturn(existing);
userService.createUserOrUpdate(actorId, req);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
// ─── createGroup ──────────────────────────────────────────────────────────
@Test

View File

@@ -33,6 +33,8 @@
"btn_back_to_overview": "Zurück zur Übersicht",
"btn_back": "Zurück",
"btn_back_to_document": "Zurück zum Dokument",
"form_label_person_type": "Typ",
"form_label_name": "Name",
"form_label_first_name": "Vorname",
"form_label_last_name": "Nachname",
"form_label_alias": "Rufname / Alias",
@@ -527,6 +529,7 @@
"person_type_INSTITUTION": "Institution",
"person_type_GROUP": "Gruppe",
"person_type_UNKNOWN": "Unbekannt",
"a11y_type_changed": "Typ geändert zu {type}",
"person_alias_add_heading": "Name hinzufuegen",
"person_alias_label_type": "Art",
"person_alias_label_last_name": "Nachname",
@@ -536,6 +539,9 @@
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
"person_alias_btn_delete": "Entfernen",
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
"validation_last_name_required": "Nachname ist Pflichtfeld.",
"validation_first_name_required": "Vorname ist Pflichtfeld.",
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",

View File

@@ -33,6 +33,8 @@
"btn_back_to_overview": "Back to overview",
"btn_back": "Back",
"btn_back_to_document": "Back to document",
"form_label_person_type": "Type",
"form_label_name": "Name",
"form_label_first_name": "First name",
"form_label_last_name": "Last name",
"form_label_alias": "Nickname / Alias",
@@ -527,6 +529,7 @@
"person_type_INSTITUTION": "Institution",
"person_type_GROUP": "Group",
"person_type_UNKNOWN": "Unknown",
"a11y_type_changed": "Type changed to {type}",
"person_alias_add_heading": "Add name",
"person_alias_label_type": "Type",
"person_alias_label_last_name": "Last name",
@@ -536,6 +539,9 @@
"person_alias_delete_body": "This name will be removed from search results.",
"person_alias_btn_delete": "Remove",
"error_alias_not_found": "The name alias was not found.",
"error_invalid_person_type": "The specified person type is not valid.",
"validation_last_name_required": "Last name is required.",
"validation_first_name_required": "First name is required.",
"error_ocr_service_unavailable": "The OCR service is not available.",
"error_ocr_job_not_found": "The OCR job was not found.",
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",

View File

@@ -33,6 +33,8 @@
"btn_back_to_overview": "Volver al resumen",
"btn_back": "Volver",
"btn_back_to_document": "Volver al documento",
"form_label_person_type": "Tipo",
"form_label_name": "Nombre",
"form_label_first_name": "Nombre",
"form_label_last_name": "Apellido",
"form_label_alias": "Apodo / Alias",
@@ -527,6 +529,7 @@
"person_type_INSTITUTION": "Institución",
"person_type_GROUP": "Grupo",
"person_type_UNKNOWN": "Desconocido",
"a11y_type_changed": "Tipo cambiado a {type}",
"person_alias_add_heading": "Agregar nombre",
"person_alias_label_type": "Tipo",
"person_alias_label_last_name": "Apellido",
@@ -536,6 +539,9 @@
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
"person_alias_btn_delete": "Eliminar",
"error_alias_not_found": "No se encontro el alias de nombre.",
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
"validation_last_name_required": "El apellido es obligatorio.",
"validation_first_name_required": "El nombre es obligatorio.",
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, afterEach } from 'vitest';
const { radioGroupNav } = await import('./radioGroupNav');
describe('radioGroupNav action', () => {
const nodes: HTMLElement[] = [];
function makeGroup(count: number): { container: HTMLElement; buttons: HTMLElement[] } {
const container = document.createElement('div');
container.setAttribute('role', 'radiogroup');
const buttons: HTMLElement[] = [];
for (let i = 0; i < count; i++) {
const btn = document.createElement('button');
btn.setAttribute('role', 'radio');
btn.setAttribute('aria-checked', i === 0 ? 'true' : 'false');
btn.setAttribute('tabindex', i === 0 ? '0' : '-1');
container.appendChild(btn);
buttons.push(btn);
}
document.body.appendChild(container);
nodes.push(container);
return { container, buttons };
}
afterEach(() => {
nodes.forEach((n) => n.remove());
nodes.length = 0;
});
it('ArrowRight moves focus to next button', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[1]);
});
it('ArrowRight wraps from last to first', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[3].focus();
buttons[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
it('ArrowLeft moves focus to previous button', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[2].focus();
buttons[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(document.activeElement).toBe(buttons[1]);
});
it('ArrowLeft wraps from first to last', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(document.activeElement).toBe(buttons[3]);
});
it('ArrowRight updates aria-checked on new button and removes it from old', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(buttons[1].getAttribute('aria-checked')).toBe('true');
expect(buttons[0].getAttribute('aria-checked')).toBe('false');
});
it('destroy removes keydown listener', () => {
const { container, buttons } = makeGroup(4);
const { destroy } = radioGroupNav(container);
destroy();
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
it('ignores non-arrow keys', () => {
const { container, buttons } = makeGroup(4);
radioGroupNav(container);
buttons[0].focus();
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(document.activeElement).toBe(buttons[0]);
});
});

View File

@@ -0,0 +1,37 @@
export function radioGroupNav(
node: HTMLElement,
onChange?: (value: string) => void
): { destroy: () => void; update: (onChange?: (value: string) => void) => void } {
let onChangeFn = onChange;
function getRadios(): HTMLElement[] {
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]'));
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
const radios = getRadios();
const current = radios.indexOf(document.activeElement as HTMLElement);
if (current === -1) return;
const delta = event.key === 'ArrowRight' ? 1 : -1;
const next = (current + delta + radios.length) % radios.length;
radios[current].setAttribute('aria-checked', 'false');
radios[next].setAttribute('aria-checked', 'true');
radios[next].focus();
onChangeFn?.(radios[next].getAttribute('value') ?? '');
}
node.addEventListener('keydown', handleKeydown);
return {
update(newOnChange) {
onChangeFn = newOnChange;
},
destroy() {
node.removeEventListener('keydown', handleKeydown);
}
};
}

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { untrack } from 'svelte';
import { radioGroupNav } from '$lib/actions/radioGroupNav';
import { m } from '$lib/paraglide/messages.js';
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
let {
value = 'PERSON',
name = 'personType',
onchange
}: { value?: string; name?: string; onchange?: (type: PersonType) => void } = $props();
let selected = $state<PersonType>(
untrack(() => (TYPES.includes(value as PersonType) ? (value as PersonType) : 'PERSON'))
);
let announcement = $state('');
const labels: Record<PersonType, () => string> = {
PERSON: m.person_type_PERSON,
INSTITUTION: m.person_type_INSTITUTION,
GROUP: m.person_type_GROUP,
UNKNOWN: m.person_type_UNKNOWN
};
function select(type: PersonType) {
selected = type;
announcement = m.a11y_type_changed({ type: labels[type]() });
onchange?.(type);
}
</script>
<div
role="radiogroup"
aria-label={m.form_label_person_type()}
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
>
{#each TYPES as type (type)}
<button
type="button"
role="radio"
value={type}
aria-checked={selected === type}
tabindex={selected === type ? 0 : -1}
onclick={() => select(type)}
class="min-h-[48px] cursor-pointer rounded-sm border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {selected === type
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-surface text-ink hover:border-primary/50'}"
>
{labels[type]()}
</button>
{/each}
</div>
<input type="hidden" name={name} value={selected} />
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { userEvent } from 'vitest/browser';
import PersonTypeSelector from './PersonTypeSelector.svelte';
afterEach(() => cleanup());
describe('PersonTypeSelector', () => {
it('radiogroup has an accessible name via aria-label', () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const radiogroup = container.querySelector('[role="radiogroup"]');
expect(radiogroup).not.toBeNull();
expect(radiogroup!.getAttribute('aria-label')).toBeTruthy();
});
it('hidden input value updates when user navigates with ArrowRight', async () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
expect(hiddenInput.value).toBe('PERSON');
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
personButton.focus();
await userEvent.keyboard('{ArrowRight}');
expect(hiddenInput.value).toBe('INSTITUTION');
});
it('hidden input value updates when user navigates with ArrowLeft (wraps around)', async () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
expect(hiddenInput.value).toBe('PERSON');
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
personButton.focus();
await userEvent.keyboard('{ArrowLeft}');
expect(hiddenInput.value).toBe('UNKNOWN');
});
it('exactly one button is aria-checked=true for the initial value', () => {
const { container } = render(PersonTypeSelector, { value: 'INSTITUTION' });
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
const checked = buttons.filter((b) => b.getAttribute('aria-checked') === 'true');
const unchecked = buttons.filter((b) => b.getAttribute('aria-checked') === 'false');
expect(checked).toHaveLength(1);
expect(unchecked).toHaveLength(3);
});
it('aria-checked=true moves to clicked button on click', async () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
const groupButton = buttons.find((b) => b.getAttribute('value') === 'GROUP') as HTMLElement;
await userEvent.click(groupButton);
expect(groupButton.getAttribute('aria-checked')).toBe('true');
const others = buttons.filter((b) => b !== groupButton);
for (const btn of others) {
expect(btn.getAttribute('aria-checked')).toBe('false');
}
});
it('selected button has tabindex=0, unselected buttons have tabindex=-1', () => {
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
const selected = buttons.find((b) => b.getAttribute('aria-checked') === 'true');
const unselected = buttons.filter((b) => b.getAttribute('aria-checked') !== 'true');
expect(selected!.getAttribute('tabindex')).toBe('0');
for (const btn of unselected) {
expect(btn.getAttribute('tabindex')).toBe('-1');
}
});
});

View File

@@ -7,6 +7,7 @@ import * as m from '$lib/paraglide/messages.js';
export type ErrorCode =
| 'PERSON_NOT_FOUND'
| 'ALIAS_NOT_FOUND'
| 'INVALID_PERSON_TYPE'
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
| 'FILE_NOT_FOUND'
@@ -73,6 +74,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_person_not_found();
case 'ALIAS_NOT_FOUND':
return m.error_alias_not_found();
case 'INVALID_PERSON_TYPE':
return m.error_invalid_person_type();
case 'DOCUMENT_NOT_FOUND':
return m.error_document_not_found();
case 'DOCUMENT_NO_FILE':

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { validatePersonFields } from './person-validation';
describe('validatePersonFields', () => {
it('returns null when all required fields are present for PERSON', () => {
expect(validatePersonFields('PERSON', 'Hans', 'Müller')).toBeNull();
});
it('returns lastName error key when lastName is missing', () => {
expect(validatePersonFields('PERSON', 'Hans', '')).toBe('validation_last_name_required');
});
it('returns lastName error key when lastName is undefined', () => {
expect(validatePersonFields('INSTITUTION', undefined, undefined)).toBe(
'validation_last_name_required'
);
});
it('returns firstName error key when type is PERSON and firstName is missing', () => {
expect(validatePersonFields('PERSON', '', 'Müller')).toBe('validation_first_name_required');
});
it('returns firstName error key when type is PERSON and firstName is undefined', () => {
expect(validatePersonFields('PERSON', undefined, 'Müller')).toBe(
'validation_first_name_required'
);
});
it('returns null for INSTITUTION without firstName', () => {
expect(validatePersonFields('INSTITUTION', undefined, 'Verlag GmbH')).toBeNull();
});
it('returns null for GROUP without firstName', () => {
expect(validatePersonFields('GROUP', undefined, 'Familie Raddatz')).toBeNull();
});
it('returns null for UNKNOWN without firstName', () => {
expect(validatePersonFields('UNKNOWN', undefined, 'Unbekannt')).toBeNull();
});
});

View File

@@ -0,0 +1,39 @@
import { m } from '$lib/paraglide/messages.js';
export const PERSON_TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
export type PersonType = (typeof PERSON_TYPES)[number];
export type PersonFormData = {
personType?: string | null;
title?: string | null;
firstName?: string | null;
lastName: string;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
notes?: string | null;
};
export function normalizePersonType(raw: string | undefined | null): PersonType {
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as PersonType);
}
export type PersonValidationKey =
| 'validation_last_name_required'
| 'validation_first_name_required';
export function resolveValidationMessage(key: PersonValidationKey): string {
return key === 'validation_last_name_required'
? m.validation_last_name_required()
: m.validation_first_name_required();
}
export function validatePersonFields(
personType: string,
firstName: string | undefined | null,
lastName: string | undefined | null
): PersonValidationKey | null {
if (!lastName) return 'validation_last_name_required';
if (personType === 'PERSON' && !firstName) return 'validation_first_name_required';
return null;
}

View File

@@ -13,6 +13,7 @@ let {
lastName: string;
displayName: string;
personType?: string | null;
title?: string | null;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
@@ -66,6 +67,14 @@ let {
</div>
</div>
{#if person.personType === 'PERSON' && person.title}
<p
class="mb-0.5 text-center font-sans text-xs tracking-widest text-ink-3 [font-variant:small-caps]"
>
{person.title}
</p>
{/if}
<!-- Name — centered, serif -->
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
{person.displayName}

View File

@@ -1,6 +1,11 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import {
normalizePersonType,
validatePersonFields,
resolveValidationMessage
} from '$lib/person-validation';
export async function load({ params, fetch, locals }) {
const canWrite =
@@ -22,12 +27,16 @@ export async function load({ params, fetch, locals }) {
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data!, aliases: aliasesResult.data ?? [] };
const person = result.data!;
const personType = normalizePersonType(person.personType);
return { person: { ...person, personType }, aliases: aliasesResult.data ?? [] };
}
export const actions = {
update: async ({ request, params, fetch }) => {
const formData = await request.formData();
const personType = normalizePersonType(formData.get('personType')?.toString());
const title = formData.get('title')?.toString().trim() || undefined;
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
@@ -37,15 +46,18 @@ export const actions = {
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
if (!firstName || !lastName) {
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
const validationKey = validatePersonFields(personType, firstName, lastName);
if (validationKey) {
return fail(400, { updateError: resolveValidationMessage(validationKey) });
}
const api = createApiClient(fetch);
const result = await api.PUT('/api/persons/{id}', {
params: { path: { id: params.id } },
body: {
firstName,
personType,
...(title ? { title } : {}),
...(firstName ? { firstName } : {}),
lastName,
...(alias ? { alias } : {}),
...(notes ? { notes } : {}),

View File

@@ -1,93 +1,117 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
import {
PERSON_TYPES as TYPES,
type PersonType,
type PersonFormData
} from '$lib/person-validation';
let {
person
}: {
person: {
firstName?: string | null;
lastName: string;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
notes?: string | null;
};
} = $props();
let { person }: { person: PersonFormData } = $props();
let selectedType = $state<PersonType>(
untrack(() =>
TYPES.includes(person.personType as PersonType) ? (person.personType as PersonType) : 'PERSON'
)
);
const isPerson = $derived(selectedType === 'PERSON');
const lastNameLabel = $derived(
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
? m.form_label_name()
: m.form_label_last_name()
);
const labelCls = 'mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase';
const inputCls =
'block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
</script>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label for="firstName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_first_name()} *</label
>
<input
id="firstName"
name="firstName"
type="text"
required
value={person.firstName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
<div class="md:col-span-2">
<p class={labelCls}>
{m.form_label_person_type()}
</p>
<PersonTypeSelector
value={selectedType}
name="personType"
onchange={(type: PersonType) => (selectedType = type)}
/>
</div>
<div>
<label for="lastName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_last_name()} *</label
>
{#if isPerson}
<div>
<label for="title" class={labelCls}>{m.form_label_title()}</label>
<input
id="title"
name="title"
type="text"
maxlength="50"
value={person.title ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
<input
id="firstName"
name="firstName"
type="text"
required
value={person.firstName ?? ''}
class={inputCls}
/>
</div>
{/if}
<div class={!isPerson ? 'md:col-span-2' : ''}>
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
<input
id="lastName"
name="lastName"
type="text"
required
value={person.lastName}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class={inputCls}
/>
</div>
{#if isPerson}
<div class="md:col-span-2">
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
<input id="alias" name="alias" type="text" value={person.alias ?? ''} class={inputCls} />
</div>
<div>
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class={inputCls}
/>
</div>
{/if}
<div class="md:col-span-2">
<label for="alias" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_alias()}</label
>
<input
id="alias"
name="alias"
type="text"
value={person.alias ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label for="birthYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_birth_year()}</label
>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label for="deathYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_death_year()}</label
>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div class="md:col-span-2">
<label for="notes" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_notes()}</label
>
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
<textarea
id="notes"
name="notes"

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { normalizePersonType } from '$lib/person-validation';
describe('edit load — SKIP → UNKNOWN normalization', () => {
it('maps SKIP to UNKNOWN', () => {
expect(normalizePersonType('SKIP')).toBe('UNKNOWN');
});
it('passes PERSON through unchanged', () => {
expect(normalizePersonType('PERSON')).toBe('PERSON');
});
it('passes INSTITUTION through unchanged', () => {
expect(normalizePersonType('INSTITUTION')).toBe('INSTITUTION');
});
it('passes GROUP through unchanged', () => {
expect(normalizePersonType('GROUP')).toBe('GROUP');
});
it('passes UNKNOWN through unchanged', () => {
expect(normalizePersonType('UNKNOWN')).toBe('UNKNOWN');
});
it('defaults null to PERSON', () => {
expect(normalizePersonType(null)).toBe('PERSON');
});
});

View File

@@ -1,5 +1,11 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
import {
normalizePersonType,
validatePersonFields,
resolveValidationMessage
} from '$lib/person-validation';
export async function load({ locals }: { locals: App.Locals }) {
const canWrite =
@@ -12,6 +18,8 @@ export async function load({ locals }: { locals: App.Locals }) {
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const personType = normalizePersonType(formData.get('personType')?.toString());
const title = formData.get('title')?.toString().trim() || undefined;
const firstName = formData.get('firstName')?.toString().trim();
const lastName = formData.get('lastName')?.toString().trim();
const alias = formData.get('alias')?.toString().trim() || undefined;
@@ -19,8 +27,16 @@ export const actions = {
const deathYearStr = formData.get('deathYear')?.toString().trim();
const notes = formData.get('notes')?.toString().trim() || undefined;
if (!firstName || !lastName) {
return fail(400, { error: 'Vor- und Nachname sind Pflichtfelder.' });
const validationKey = validatePersonFields(personType, firstName, lastName);
if (validationKey) {
return fail(400, {
error: resolveValidationMessage(validationKey),
personType,
title,
firstName: firstName ?? '',
lastName: lastName ?? '',
alias
});
}
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
@@ -29,8 +45,10 @@ export const actions = {
const api = createApiClient(fetch);
const result = await api.POST('/api/persons', {
body: {
firstName,
lastName,
personType,
...(title ? { title } : {}),
...(firstName ? { firstName } : {}),
lastName: lastName!,
...(alias ? { alias } : {}),
...(birthYear ? { birthYear } : {}),
...(deathYear ? { deathYear } : {}),
@@ -39,7 +57,15 @@ export const actions = {
});
if (!result.response.ok) {
return fail(result.response.status, { error: 'Person konnte nicht gespeichert werden.' });
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, {
error: getErrorMessage(code),
personType,
title,
firstName,
lastName: lastName!,
alias
});
}
throw redirect(303, `/persons/${result.data!.id}`);

View File

@@ -1,11 +1,33 @@
<script lang="ts">
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import BackButton from '$lib/components/BackButton.svelte';
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
let { form } = $props();
let selectedType = $state<PersonType>(
untrack(() =>
TYPES.includes((form?.personType as PersonType) ?? 'PERSON')
? ((form?.personType as PersonType) ?? 'PERSON')
: 'PERSON'
)
);
const isPerson = $derived(selectedType === 'PERSON');
const lastNameLabel = $derived(
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
? m.form_label_name()
: m.form_label_last_name()
);
const inputCls =
'block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
const labelCls = 'mb-1 block text-sm font-medium text-ink-2';
</script>
<div class="mx-auto max-w-2xl px-4 py-8">
<!-- Heading -->
<div class="mb-6">
<BackButton />
<h1 class="font-serif text-3xl text-ink">{m.persons_new_heading()}</h1>
@@ -22,79 +44,92 @@ let { form } = $props();
</h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<div>
<label for="firstName" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_first_name()} *</label
>
<input
id="firstName"
name="firstName"
type="text"
required
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
<div class="md:col-span-2">
<p class={labelCls}>{m.form_label_person_type()}</p>
<PersonTypeSelector
value={selectedType}
name="personType"
onchange={(type: PersonType) => (selectedType = type)}
/>
</div>
<div>
<label for="lastName" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_last_name()} *</label
>
{#if isPerson}
<div>
<label for="title" class={labelCls}>{m.form_label_title()}</label>
<input
id="title"
name="title"
type="text"
maxlength="50"
value={form?.title ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
<input
id="firstName"
name="firstName"
type="text"
required
value={form?.firstName ?? ''}
class={inputCls}
/>
</div>
{/if}
<div class={!isPerson ? 'md:col-span-2' : ''}>
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
<input
id="lastName"
name="lastName"
type="text"
required
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
value={form?.lastName ?? ''}
class={inputCls}
/>
</div>
{#if isPerson}
<div class="md:col-span-2">
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
<input
id="alias"
name="alias"
type="text"
placeholder={m.form_placeholder_alias()}
value={form?.alias ?? ''}
class={inputCls}
/>
</div>
<div>
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class={inputCls}
/>
</div>
<div>
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class={inputCls}
/>
</div>
{/if}
<div class="md:col-span-2">
<label for="alias" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_alias()}</label
>
<input
id="alias"
name="alias"
type="text"
placeholder={m.form_placeholder_alias()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label for="birthYear" class="mb-1 block text-sm font-medium text-ink-2"
>{m.person_label_birth_year()}</label
>
<input
id="birthYear"
name="birthYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div>
<label for="deathYear" class="mb-1 block text-sm font-medium text-ink-2"
>{m.person_label_death_year()}</label
>
<input
id="deathYear"
name="deathYear"
type="number"
min="1"
max="2100"
placeholder={m.person_placeholder_year()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<div class="md:col-span-2">
<label for="notes" class="mb-1 block text-sm font-medium text-ink-2"
>{m.person_label_notes()}</label
>
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
<textarea
id="notes"
name="notes"
@@ -106,7 +141,6 @@ let { form } = $props();
</div>
</div>
<!-- Save Bar -->
<div
class="mt-4 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
>