Compare commits

..

23 Commits

Author SHA1 Message Date
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
22 changed files with 111 additions and 665 deletions

View File

@@ -26,16 +26,7 @@ public enum AuditKind {
COMMENT_ADDED,
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
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;
MENTION_CREATED;
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,

View File

@@ -1,7 +1,5 @@
package org.raddatz.familienarchiv.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -199,6 +197,4 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
ORDER BY ranked.document_id, ranked.rn
""", nativeQuery = true)
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
Page<AuditLog> findByKindIn(Collection<AuditKind> kinds, Pageable pageable);
}

View File

@@ -1,17 +1,11 @@
package org.raddatz.familienarchiv.audit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
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 {
@@ -57,11 +51,6 @@ public class AuditLogQueryService {
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
}
public List<AuditLog> findRecentUserManagementEvents(int limit) {
PageRequest page = PageRequest.of(0, limit, Sort.by("happenedAt").descending());
return queryRepository.findByKindIn(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), page).getContent();
}
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
for (ContributorRow row : rows) {

View File

@@ -5,5 +5,4 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
boolean existsByKind(AuditKind kind);
}

View File

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

View File

@@ -87,7 +87,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
SELECT d.id FROM documents d
CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('simple', regexp_replace(
THEN to_tsquery('german', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',
@@ -149,7 +149,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
FROM documents d
CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('simple', regexp_replace(
THEN to_tsquery('german', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',

View File

@@ -3,8 +3,6 @@ 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;
@@ -23,13 +21,10 @@ 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
@@ -38,10 +33,9 @@ public class UserService {
private final AppUserRepository userRepository;
private final UserGroupRepository groupRepository;
private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
@Transactional
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
public AppUser createUserOrUpdate(CreateUserRequest request) {
log.info("Creating or updating user: {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>();
@@ -51,12 +45,10 @@ 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()
@@ -69,42 +61,8 @@ public class UserService {
.contact(request.getContact())
.enabled(true)
.build();
isNew = true;
}
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
public AppUser createUserForBootstrap(CreateUserRequest request) {
log.info("Bootstrap user creation (no audit): {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>();
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
}
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
if (existingUser.isPresent()) {
AppUser updated = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
return userRepository.save(updated);
}
AppUser user = AppUser.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getInitialPassword()))
.groups(groups)
.firstName(request.getFirstName())
.lastName(request.getLastName())
.birthDate(request.getBirthDate())
.contact(request.getContact())
.enabled(true)
.build();
return userRepository.save(user);
}
@@ -136,13 +94,10 @@ public class UserService {
}
@Transactional
public void deleteUser(UUID actorId, UUID userId) {
public void deleteUser(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) {
@@ -186,7 +141,7 @@ public class UserService {
}
@Transactional
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
AppUser user = getById(id);
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
@@ -211,27 +166,13 @@ public class UserService {
}
if (dto.getGroupIds() != null) {
Set<UserGroup> before = new HashSet<>(user.getGroups());
Set<UserGroup> after = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
user.setGroups(after);
groupChangePayload(before, after, id, user.getEmail())
.ifPresent(payload -> auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, payload));
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
user.setGroups(groups);
}
return userRepository.save(user);
}
private Optional<Map<String, Object>> groupChangePayload(
Set<UserGroup> before, Set<UserGroup> after, UUID userId, String email) {
Set<UUID> beforeIds = before.stream().map(UserGroup::getId).collect(toSet());
Set<UUID> afterIds = after.stream().map(UserGroup::getId).collect(toSet());
if (beforeIds.equals(afterIds)) return Optional.empty();
List<String> added = after.stream().filter(g -> !beforeIds.contains(g.getId())).map(UserGroup::getName).toList();
List<String> removed = before.stream().filter(g -> !afterIds.contains(g.getId())).map(UserGroup::getName).toList();
return Optional.of(Map.of("userId", userId.toString(), "email", email,
"addedGroups", added, "removedGroups", removed));
}
@Transactional
public void changePassword(UUID userId, ChangePasswordDTO dto) {
AppUser user = getById(userId);

View File

@@ -6,19 +6,12 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.model.AppUser;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
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.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -54,21 +47,4 @@ 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.findByKindIn(anyCollection(), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(entry)));
List<AuditLog> result = auditLogQueryService.findRecentUserManagementEvents(5);
assertThat(result).containsExactly(entry);
verify(queryRepository).findByKindIn(
argThat((Collection<AuditKind> kinds) ->
kinds.contains(AuditKind.USER_CREATED) &&
kinds.contains(AuditKind.USER_DELETED) &&
kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)),
any(Pageable.class));
}
}

View File

@@ -1,122 +0,0 @@
package org.raddatz.familienarchiv.audit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.GroupDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.UserGroup;
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.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.Set;
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)
class UserManagementAuditIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired UserService userService;
@Autowired AppUserRepository userRepository;
@Autowired AuditLogRepository auditLogRepository;
@Autowired AuditLogQueryService auditLogQueryService;
@Autowired TransactionTemplate transactionTemplate;
@BeforeEach
void clearAuditLog() {
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
}
@Test
void createAndDeleteUser_producesOrderedAuditEntries() {
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
CreateUserRequest adminReq = new CreateUserRequest();
adminReq.setEmail("admin@test.example.com");
adminReq.setInitialPassword("admin-secret");
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(adminReq));
UUID actorId = actor.getId();
// 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(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
// 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(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED));
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);
}
@Test
void updateUserGroups_producesGroupMembershipChangedEvent() {
GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL"));
GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL"));
UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto));
UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto));
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
CreateUserRequest actorReq = new CreateUserRequest();
actorReq.setEmail("actor-group-test@test.example.com");
actorReq.setInitialPassword("secret");
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(actorReq));
// Create target user pre-assigned to gA — emits USER_CREATED
CreateUserRequest targetReq = new CreateUserRequest();
targetReq.setEmail("target-group-test@test.example.com");
targetReq.setInitialPassword("secret");
targetReq.setGroupIds(List.of(gA.getId()));
transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq));
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow();
// Change groups: Viewers → Editors
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(gB.getId()));
transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto));
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED));
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
assertThat(events).hasSize(1);
AuditLog event = events.get(0);
assertThat(event.getKind()).isEqualTo(AuditKind.GROUP_MEMBERSHIP_CHANGED);
assertThat(event.getPayload()).containsEntry("email", "target-group-test@test.example.com");
@SuppressWarnings("unchecked")
List<String> added = (List<String>) event.getPayload().get("addedGroups");
@SuppressWarnings("unchecked")
List<String> removed = (List<String>) event.getPayload().get("removedGroups");
assertThat(added).containsExactlyInAnyOrder("Editors");
assertThat(removed).containsExactlyInAnyOrder("Viewers");
}
}

View File

@@ -18,10 +18,8 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
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.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -106,55 +104,4 @@ class UserControllerTest {
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest());
}
// ─── permission enforcement ───────────────────────────────────────────────
@Test
@WithMockUser(username = "reader@example.com")
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "reader@example.com")
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "reader@example.com")
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
// ─── unauthenticated access ───────────────────────────────────────────────
@Test
void createUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isUnauthorized());
}
@Test
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void deleteUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -179,22 +179,6 @@ class DocumentFtsTest {
assertThat(ids).isEmpty();
}
@Test
void should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word() {
// "Wille" stems to "will" via the German Snowball stemmer.
// "will" is also a German stop word, so to_tsquery('german','will:*') drops it.
// The prefix-transform step must use to_tsquery('simple',...) to avoid this.
Document doc = documentRepository.saveAndFlush(document("Foto"));
UUID annotationId = annotation(doc.getId());
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Der Wille des Volkes", 0));
em.flush();
em.clear();
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
assertThat(ids).contains(doc.getId());
}
@Test
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
documentRepository.saveAndFlush(document("Brief"));

View File

@@ -2,12 +2,9 @@ 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;
@@ -37,7 +34,6 @@ class UserServiceTest {
@Mock AppUserRepository userRepository;
@Mock UserGroupRepository groupRepository;
@Mock PasswordEncoder passwordEncoder;
@Mock AuditService auditService;
@InjectMocks UserService userService;
// ─── findByEmail ──────────────────────────────────────────────────────────
@@ -65,7 +61,7 @@ class UserServiceTest {
UUID id = UUID.randomUUID();
when(userRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id))
assertThatThrownBy(() -> userService.deleteUser(id))
.isInstanceOf(DomainException.class);
}
@@ -75,7 +71,7 @@ class UserServiceTest {
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
userService.deleteUser(UUID.randomUUID(), id);
userService.deleteUser(id);
verify(userRepository).delete(user);
}
@@ -94,7 +90,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(UUID.randomUUID(), req);
AppUser result = userService.createUserOrUpdate(req);
assertThat(result).isEqualTo(saved);
verify(userRepository).save(any());
@@ -112,7 +108,7 @@ class UserServiceTest {
when(passwordEncoder.encode(any())).thenReturn("encoded");
when(userRepository.save(any())).thenReturn(existing);
userService.createUserOrUpdate(UUID.randomUUID(), req);
userService.createUserOrUpdate(req);
verify(userRepository, times(1)).save(existing);
}
@@ -233,7 +229,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getFirstName()).isEqualTo("Ada");
assertThat(result.getLastName()).isEqualTo("Lovelace");
@@ -250,7 +246,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getGroups()).containsExactly(adminGroup);
}
@@ -268,7 +264,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(newGroup.getId()));
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getGroups()).containsExactly(newGroup);
}
@@ -285,7 +281,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of());
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getGroups()).isEmpty();
}
@@ -317,7 +313,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(UUID.randomUUID(), req);
AppUser result = userService.createUserOrUpdate(req);
assertThat(result).isEqualTo(saved);
verify(groupRepository).findAllById(List.of(group.getId()));
@@ -382,7 +378,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword("newSecret");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getPassword()).isEqualTo("newHashed");
}
@@ -397,7 +393,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword(" ");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getPassword()).isEqualTo("original");
verify(passwordEncoder, never()).encode(any());
@@ -412,7 +408,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(" ");
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("blank");
}
@@ -429,7 +425,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("taken@example.com");
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("E-Mail");
}
@@ -501,7 +497,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(UUID.randomUUID(), req);
userService.createUserOrUpdate(req);
verify(groupRepository, never()).findAllById(any());
}
@@ -565,7 +561,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(null);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getContact()).isNull();
}
@@ -580,7 +576,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" ");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getContact()).isNull();
}
@@ -595,7 +591,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" phone: 555 ");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getContact()).isEqualTo("phone: 555");
}
@@ -610,7 +606,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(null);
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getEmail()).isEqualTo("keep@example.com");
}
@@ -626,7 +622,7 @@ class UserServiceTest {
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("me@example.com");
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getEmail()).isEqualTo("me@example.com");
}
@@ -644,7 +640,7 @@ class UserServiceTest {
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(UUID.randomUUID(), req);
userService.createUserOrUpdate(req);
verify(groupRepository, never()).findAllById(any());
}
@@ -703,160 +699,6 @@ 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());
}
// ─── createUserForBootstrap ───────────────────────────────────────────────
@Test
void createUserForBootstrap_createsUserWithoutAuditEvent() {
CreateUserRequest req = new CreateUserRequest();
req.setEmail("bootstrap@example.com");
req.setInitialPassword("secret");
req.setGroupIds(List.of());
when(userRepository.findByEmail("bootstrap@example.com")).thenReturn(Optional.empty());
when(passwordEncoder.encode("secret")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("bootstrap@example.com").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserForBootstrap(req);
assertThat(result).isEqualTo(saved);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
// ─── createGroup ──────────────────────────────────────────────────────────
@Test

View File

@@ -40,26 +40,6 @@ export default defineConfig(
parser: ts.parser,
svelteConfig
}
},
rules: {
// text-accent resolves to #a1dcd8 in light mode (1.52:1 on white — WCAG fail).
// layout.css documents it as decorative-only (borders, icon tints, bg fills).
// For any text label use text-primary or text-ink instead. This rule catches
// the pattern where text-accent appears inside a JavaScript string literal
// (e.g. conditional ternary class expressions in Svelte templates).
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/\\btext-accent\\b/]',
message:
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
},
{
selector: 'TemplateLiteral > TemplateElement[value.raw=/\\btext-accent\\b/]',
message:
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
}
]
}
}
);

View File

@@ -91,7 +91,7 @@ let {
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
? 'text-ink-2 hover:bg-surface/10'
: 'bg-surface/10 text-primary'}"
: 'bg-surface/10 text-accent'}"
>
<svg
class="h-3.5 w-3.5 shrink-0"

View File

@@ -1,67 +0,0 @@
import { vi, describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PdfControls from './PdfControls.svelte';
afterEach(cleanup);
const defaultProps = {
currentPage: 1,
totalPages: 3,
isLoaded: true,
showAnnotations: false,
annotationCount: 0,
onPrev: vi.fn(),
onNext: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onToggleAnnotations: vi.fn()
};
describe('PdfControls — annotation toggle visibility', () => {
it('renders annotation toggle when annotationCount is greater than zero', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 3 });
await expect
.element(page.getByRole('button', { name: /annotierungen anzeigen/i }))
.toBeInTheDocument();
});
it('does not render annotation toggle when annotationCount is zero', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 0 });
await expect
.element(page.getByRole('button', { name: /annotierungen/i }))
.not.toBeInTheDocument();
});
});
describe('PdfControls — annotation toggle label', () => {
it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false });
const btn = page.getByRole('button', { name: /annotierungen anzeigen/i });
await expect.element(btn).toBeInTheDocument();
});
it('shows "Annotierungen verbergen" label when annotations are visible', async () => {
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true });
const btn = page.getByRole('button', { name: /annotierungen verbergen/i });
await expect.element(btn).toBeInTheDocument();
});
});
describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
it('uses text-primary class on annotation toggle button when annotations are hidden', async () => {
const { container } = render(PdfControls, {
...defaultProps,
annotationCount: 2,
showAnnotations: false
});
const allButtons = container.querySelectorAll('button');
const annotationBtn = Array.from(allButtons).find((b) =>
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
);
expect(annotationBtn).not.toBeNull();
expect(annotationBtn!.className).toContain('text-primary');
expect(annotationBtn!.className).not.toContain('text-accent');
});
});

View File

@@ -32,7 +32,6 @@ function select(type: PersonType) {
<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); }}
>

View File

@@ -7,13 +7,6 @@ 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;
@@ -25,47 +18,32 @@ describe('PersonTypeSelector', () => {
expect(hiddenInput.value).toBe('INSTITUTION');
});
it('hidden input value updates when user navigates with ArrowLeft (wraps around)', async () => {
it('selected button uses semantic bg-primary and text-primary-fg classes', () => {
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);
const buttons = container.querySelectorAll('[role="radio"]');
const selected = Array.from(buttons).find((b) => b.getAttribute('aria-checked') === 'true');
expect(selected).not.toBeNull();
expect(selected!.className).toContain('bg-primary');
expect(selected!.className).toContain('text-primary-fg');
});
it('aria-checked=true moves to clicked button on click', async () => {
it('unselected buttons use semantic bg-surface, text-ink, border-line classes', () => {
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');
const buttons = container.querySelectorAll('[role="radio"]');
const unselected = Array.from(buttons).filter((b) => b.getAttribute('aria-checked') !== 'true');
expect(unselected.length).toBeGreaterThan(0);
for (const btn of unselected) {
expect(btn.className).toContain('bg-surface');
expect(btn.className).toContain('text-ink');
expect(btn.className).toContain('border-line');
}
});
it('selected button has tabindex=0, unselected buttons have tabindex=-1', () => {
it('focus ring uses semantic ring-focus-ring class', () => {
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');
const buttons = container.querySelectorAll('[role="radio"]');
for (const btn of buttons) {
expect(btn.className).toContain('ring-focus-ring');
}
});
});

View File

@@ -19,7 +19,7 @@ let { percentage }: { percentage: number } = $props();
/>
</svg>
<span
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-primary' : 'text-gray-400'}"
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-accent' : 'text-gray-400'}"
>
{percentage}%
</span>

View File

@@ -25,12 +25,12 @@ describe('ProgressRing', () => {
expect(el.className).toContain('text-gray-400');
});
it('renders a primary-colored label when percentage is > 0', async () => {
it('renders a mint-colored label when percentage is > 0', async () => {
render(ProgressRing, { percentage: 75 });
const label = page.getByText('75%');
await expect.element(label).toBeInTheDocument();
const el = (await label.element()) as HTMLElement;
expect(el.className).toContain('text-primary');
expect(el.className).toContain('text-accent');
});
it('renders a fully filled arc for 100%', async () => {

View File

@@ -3,17 +3,6 @@ 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);
}

View File

@@ -2,13 +2,22 @@
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';
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
let { person }: { person: PersonFormData } = $props();
let {
person
}: {
person: {
personType?: string | null;
title?: string | null;
firstName?: string | null;
lastName: string;
alias?: string | null;
birthYear?: number | null;
deathYear?: number | null;
notes?: string | null;
};
} = $props();
let selectedType = $state<PersonType>(
untrack(() =>
@@ -22,15 +31,11 @@ const lastNameLabel = $derived(
? 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 class="md:col-span-2">
<p class={labelCls}>
<p class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_person_type()}
</p>
<PersonTypeSelector
@@ -42,48 +47,68 @@ const inputCls =
{#if isPerson}
<div>
<label for="title" class={labelCls}>{m.form_label_title()}</label>
<label for="title" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.form_label_title()}</label
>
<input
id="title"
name="title"
type="text"
maxlength="50"
value={person.title ?? ''}
class={inputCls}
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="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
<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={inputCls}
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>
{/if}
<div class={!isPerson ? 'md:col-span-2' : ''}>
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
<label for="lastName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{lastNameLabel} *</label
>
<input
id="lastName"
name="lastName"
type="text"
required
value={person.lastName}
class={inputCls}
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>
{#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} />
<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={labelCls}>{m.person_label_birth_year()}</label>
<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"
@@ -92,11 +117,15 @@ const inputCls =
max="2100"
placeholder={m.person_placeholder_year()}
value={person.birthYear ?? ''}
class={inputCls}
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={labelCls}>{m.person_label_death_year()}</label>
<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"
@@ -105,13 +134,15 @@ const inputCls =
max="2100"
placeholder={m.person_placeholder_year()}
value={person.deathYear ?? ''}
class={inputCls}
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>
{/if}
<div class="md:col-span-2">
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
<label for="notes" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.person_label_notes()}</label
>
<textarea
id="notes"
name="notes"