Compare commits
18 Commits
feat/issue
...
0c47c22185
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c47c22185 | ||
|
|
d97cd06f70 | ||
|
|
b41405cb4b | ||
|
|
c9e9300216 | ||
|
|
92b082ec62 | ||
|
|
8770ca874b | ||
|
|
e54240ea1b | ||
|
|
437144174c | ||
|
|
fe830ad64b | ||
|
|
8f75552503 | ||
|
|
e7573bbeda | ||
|
|
bf31380141 | ||
|
|
58b3dabea2 | ||
|
|
60a278ad8e | ||
|
|
39f722fec0 | ||
|
|
fef021bf51 | ||
|
|
6c117611b8 | ||
|
|
aac8250af0 |
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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'':*',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -272,8 +272,7 @@ class PersonControllerTest {
|
||||
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"));
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -540,8 +540,6 @@
|
||||
"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.",
|
||||
|
||||
@@ -540,8 +540,6 @@
|
||||
"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.",
|
||||
|
||||
@@ -540,8 +540,6 @@
|
||||
"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.",
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export function radioGroupNav(
|
||||
node: HTMLElement,
|
||||
onChange?: (value: string) => void
|
||||
): { destroy: () => void; update: (onChange?: (value: string) => void) => void } {
|
||||
let onChangeFn = onChange;
|
||||
|
||||
export function radioGroupNav(node: HTMLElement): { destroy: () => void } {
|
||||
function getRadios(): HTMLElement[] {
|
||||
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]'));
|
||||
}
|
||||
@@ -21,15 +16,11 @@ export function radioGroupNav(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@
|
||||
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';
|
||||
|
||||
const TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
||||
type PersonType = (typeof TYPES)[number];
|
||||
|
||||
let {
|
||||
value = 'PERSON',
|
||||
@@ -14,8 +16,6 @@ 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,
|
||||
@@ -25,22 +25,15 @@ const labels: Record<PersonType, () => string> = {
|
||||
|
||||
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); }}
|
||||
>
|
||||
<div role="radiogroup" class="grid grid-cols-2 gap-2 sm:grid-cols-4" use:radioGroupNav>
|
||||
{#each TYPES as type (type)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
value={type}
|
||||
aria-checked={selected === type}
|
||||
tabindex={selected === type ? 0 : -1}
|
||||
onclick={() => select(type)}
|
||||
@@ -55,4 +48,6 @@ function select(type: PersonType) {
|
||||
|
||||
<input type="hidden" name={name} value={selected} />
|
||||
|
||||
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>
|
||||
<div class="sr-only" aria-live="polite">
|
||||
{m.a11y_type_changed({ type: labels[selected]() })}
|
||||
</div>
|
||||
|
||||
@@ -1,71 +1,37 @@
|
||||
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', () => {
|
||||
it('selected button uses semantic bg-primary and text-primary-fg classes', () => {
|
||||
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||
const radiogroup = container.querySelector('[role="radiogroup"]');
|
||||
expect(radiogroup).not.toBeNull();
|
||||
expect(radiogroup!.getAttribute('aria-label')).toBeTruthy();
|
||||
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('hidden input value updates when user navigates with ArrowRight', async () => {
|
||||
it('unselected buttons use semantic bg-surface, text-ink, border-line 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('{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');
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -6,24 +6,22 @@ describe('validatePersonFields', () => {
|
||||
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 when lastName is missing', () => {
|
||||
expect(validatePersonFields('PERSON', 'Hans', '')).toBe('Nachname ist Pflichtfeld.');
|
||||
});
|
||||
|
||||
it('returns lastName error key when lastName is undefined', () => {
|
||||
it('returns lastName error when lastName is undefined', () => {
|
||||
expect(validatePersonFields('INSTITUTION', undefined, undefined)).toBe(
|
||||
'validation_last_name_required'
|
||||
'Nachname ist Pflichtfeld.'
|
||||
);
|
||||
});
|
||||
|
||||
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 when type is PERSON and firstName is missing', () => {
|
||||
expect(validatePersonFields('PERSON', '', 'Müller')).toBe('Vorname ist Pflichtfeld.');
|
||||
});
|
||||
|
||||
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 firstName error when type is PERSON and firstName is undefined', () => {
|
||||
expect(validatePersonFields('PERSON', undefined, 'Müller')).toBe('Vorname ist Pflichtfeld.');
|
||||
});
|
||||
|
||||
it('returns null for INSTITUTION without firstName', () => {
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
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';
|
||||
): string | null {
|
||||
if (!lastName) return 'Nachname ist Pflichtfeld.';
|
||||
if (personType === 'PERSON' && !firstName) return 'Vorname ist Pflichtfeld.';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ let {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title (academic/honorific) — PERSON type only -->
|
||||
{#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]"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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';
|
||||
import { validatePersonFields } from '$lib/person-validation';
|
||||
|
||||
type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN' | 'SKIP';
|
||||
|
||||
export function normalizePersonType(raw: string | undefined | null): Exclude<PersonType, 'SKIP'> {
|
||||
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as Exclude<PersonType, 'SKIP'>);
|
||||
}
|
||||
|
||||
export async function load({ params, fetch, locals }) {
|
||||
const canWrite =
|
||||
@@ -35,7 +37,11 @@ export async function load({ params, fetch, locals }) {
|
||||
export const actions = {
|
||||
update: async ({ request, params, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const personType = normalizePersonType(formData.get('personType')?.toString());
|
||||
const personType = (formData.get('personType')?.toString() ?? 'PERSON') as
|
||||
| 'PERSON'
|
||||
| 'INSTITUTION'
|
||||
| 'GROUP'
|
||||
| 'UNKNOWN';
|
||||
const title = formData.get('title')?.toString().trim() || undefined;
|
||||
const firstName = formData.get('firstName')?.toString().trim();
|
||||
const lastName = formData.get('lastName')?.toString().trim();
|
||||
@@ -46,9 +52,9 @@ export const actions = {
|
||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
||||
|
||||
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||
if (validationKey) {
|
||||
return fail(400, { updateError: resolveValidationMessage(validationKey) });
|
||||
const validationError = validatePersonFields(personType, firstName, lastName);
|
||||
if (validationError) {
|
||||
return fail(400, { updateError: validationError });
|
||||
}
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
@@ -2,14 +2,25 @@
|
||||
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: PersonFormData } = $props();
|
||||
type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN';
|
||||
|
||||
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();
|
||||
|
||||
const TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
||||
let selectedType = $state<PersonType>(
|
||||
untrack(() =>
|
||||
TYPES.includes(person.personType as PersonType) ? (person.personType as PersonType) : 'PERSON'
|
||||
@@ -22,15 +33,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 +49,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 +119,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 +136,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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizePersonType } from '$lib/person-validation';
|
||||
import { normalizePersonType } from './+page.server';
|
||||
|
||||
describe('edit load — SKIP → UNKNOWN normalization', () => {
|
||||
it('maps SKIP to UNKNOWN', () => {
|
||||
@@ -1,11 +1,7 @@
|
||||
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';
|
||||
import { validatePersonFields } from '$lib/person-validation';
|
||||
|
||||
export async function load({ locals }: { locals: App.Locals }) {
|
||||
const canWrite =
|
||||
@@ -18,7 +14,11 @@ 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 personType = (formData.get('personType')?.toString() ?? 'PERSON') as
|
||||
| 'PERSON'
|
||||
| 'INSTITUTION'
|
||||
| 'GROUP'
|
||||
| 'UNKNOWN';
|
||||
const title = formData.get('title')?.toString().trim() || undefined;
|
||||
const firstName = formData.get('firstName')?.toString().trim();
|
||||
const lastName = formData.get('lastName')?.toString().trim();
|
||||
@@ -27,10 +27,10 @@ export const actions = {
|
||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||
|
||||
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||
if (validationKey) {
|
||||
const validationError = validatePersonFields(personType, firstName, lastName);
|
||||
if (validationError) {
|
||||
return fail(400, {
|
||||
error: resolveValidationMessage(validationKey),
|
||||
error: validationError,
|
||||
personType,
|
||||
title,
|
||||
firstName: firstName ?? '',
|
||||
|
||||
@@ -3,7 +3,9 @@ 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';
|
||||
|
||||
type PersonType = 'PERSON' | 'INSTITUTION' | 'GROUP' | 'UNKNOWN';
|
||||
const TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
||||
|
||||
let { form } = $props();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user