Compare commits
50 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35c2c83996 | ||
|
|
c317c085aa | ||
|
|
bc805cb178 | ||
|
|
ce41e96a45 | ||
|
|
a6c8af0971 | ||
|
|
6d9910b805 | ||
|
|
1dd6e054fc | ||
|
|
23cff1cdd7 | ||
|
|
11d93919b2 | ||
|
|
f6bcc4f72a | ||
|
|
f4a4436eda | ||
|
|
1d3a3b3338 | ||
|
|
77affcfb4f | ||
|
|
36529f7e11 | ||
|
|
eb8f9d4dc4 | ||
|
|
a736b7399a | ||
|
|
e7c7f801c9 | ||
|
|
5062513ae6 | ||
|
|
24d5381775 | ||
|
|
826283afcb | ||
|
|
1d5f99a2c8 | ||
|
|
5961bfb916 | ||
|
|
4c300da65e | ||
|
|
bccff232fe | ||
|
|
327fd89cb9 | ||
|
|
23861055d1 | ||
|
|
2ddeb485e3 | ||
|
|
1f19fa3462 | ||
|
|
7ef1ab3b01 | ||
|
|
45db75bdf2 | ||
|
|
8870cbe2fe | ||
|
|
b4cf7f1b21 | ||
|
|
d5587d1b95 | ||
|
|
7699a4e7e2 | ||
|
|
110416d68b | ||
|
|
64fdc5b57e | ||
|
|
ac8d0d5796 | ||
|
|
b8dcb2d3f4 | ||
|
|
ecd531601a | ||
|
|
fe1101f9d5 | ||
|
|
928ebca056 | ||
|
|
5dd4a01995 | ||
|
|
f4132edc2b | ||
|
|
d952fab4cd | ||
|
|
d45739cb76 | ||
|
|
18cad798fc | ||
|
|
0ddf43947b | ||
|
|
45f7642f8d | ||
|
|
5a13e61357 | ||
|
|
a91ee1f26d |
@@ -26,7 +26,16 @@ public enum AuditKind {
|
|||||||
COMMENT_ADDED,
|
COMMENT_ADDED,
|
||||||
|
|
||||||
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
||||||
MENTION_CREATED;
|
MENTION_CREATED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
||||||
|
USER_CREATED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
||||||
|
USER_DELETED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
|
||||||
|
GROUP_MEMBERSHIP_CHANGED;
|
||||||
|
|
||||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
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.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -197,4 +199,6 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
|||||||
ORDER BY ranked.document_id, ranked.rn
|
ORDER BY ranked.document_id, ranked.rn
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
||||||
|
|
||||||
|
Page<AuditLog> findByKindIn(Collection<AuditKind> kinds, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
package org.raddatz.familienarchiv.audit;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.*;
|
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
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuditLogQueryService {
|
public class AuditLogQueryService {
|
||||||
@@ -51,6 +57,11 @@ public class AuditLogQueryService {
|
|||||||
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
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) {
|
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
||||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
||||||
for (ContributorRow row : rows) {
|
for (ContributorRow row : rows) {
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
||||||
|
boolean existsByKind(AuditKind kind);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,27 +63,33 @@ public class PersonController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
validatePersonNames(dto);
|
||||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
|
||||||
}
|
|
||||||
dto.setFirstName(dto.getFirstName().trim());
|
|
||||||
dto.setLastName(dto.getLastName().trim());
|
dto.setLastName(dto.getLastName().trim());
|
||||||
|
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||||
return ResponseEntity.ok(personService.createPerson(dto));
|
return ResponseEntity.ok(personService.createPerson(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
validatePersonNames(dto);
|
||||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
|
||||||
}
|
|
||||||
dto.setFirstName(dto.getFirstName().trim());
|
|
||||||
dto.setLastName(dto.getLastName().trim());
|
dto.setLastName(dto.getLastName().trim());
|
||||||
|
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validatePersonNames(PersonUpdateDTO dto) {
|
||||||
|
if (dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nachname ist Pflichtfeld");
|
||||||
|
}
|
||||||
|
if (dto.getPersonType() == org.raddatz.familienarchiv.model.PersonType.PERSON
|
||||||
|
&& (dto.getFirstName() == null || dto.getFirstName().isBlank())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vorname ist Pflichtfeld");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/merge")
|
@PostMapping("/{id}/merge")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
|||||||
@@ -78,24 +78,31 @@ public class UserController {
|
|||||||
|
|
||||||
@PostMapping("/users")
|
@PostMapping("/users")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
|
public ResponseEntity<AppUser> createUser(Authentication authentication,
|
||||||
return ResponseEntity.ok(userService.createUserOrUpdate(request));
|
@Valid @RequestBody CreateUserRequest request) {
|
||||||
|
return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/users/{id}")
|
@PutMapping("/users/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
|
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
|
||||||
|
@PathVariable UUID id,
|
||||||
@RequestBody AdminUpdateUserRequest dto) {
|
@RequestBody AdminUpdateUserRequest dto) {
|
||||||
AppUser updated = userService.adminUpdateUser(id, dto);
|
AppUser updated = userService.adminUpdateUser(actorId(authentication), id, dto);
|
||||||
updated.setPassword(null);
|
updated.setPassword(null);
|
||||||
return ResponseEntity.ok(updated);
|
return ResponseEntity.ok(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/users/{id}")
|
@DeleteMapping("/users/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
|
public ResponseEntity<Void> deleteUser(Authentication authentication,
|
||||||
userService.deleteUser(id);
|
@PathVariable UUID id) {
|
||||||
|
userService.deleteUser(actorId(authentication), id);
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UUID actorId(Authentication auth) {
|
||||||
|
return userService.findByEmail(auth.getName()).getId();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public class DocumentUpdateDTO {
|
|||||||
private LocalDate documentDate;
|
private LocalDate documentDate;
|
||||||
private String location;
|
private String location;
|
||||||
private String documentLocation;
|
private String documentLocation;
|
||||||
|
private String archiveBox;
|
||||||
|
private String archiveFolder;
|
||||||
private String transcription;
|
private String transcription;
|
||||||
private String summary;
|
private String summary;
|
||||||
private UUID senderId;
|
private UUID senderId;
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonType;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PersonUpdateDTO {
|
public class PersonUpdateDTO {
|
||||||
|
@NotNull
|
||||||
|
private PersonType personType;
|
||||||
@Size(max = 50)
|
@Size(max = 50)
|
||||||
private String title;
|
private String title;
|
||||||
@Size(max = 100)
|
@Size(max = 100)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public enum ErrorCode {
|
|||||||
PERSON_NOT_FOUND,
|
PERSON_NOT_FOUND,
|
||||||
/** A person name alias with the given ID does not exist. 404 */
|
/** A person name alias with the given ID does not exist. 404 */
|
||||||
ALIAS_NOT_FOUND,
|
ALIAS_NOT_FOUND,
|
||||||
|
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||||
|
INVALID_PERSON_TYPE,
|
||||||
|
|
||||||
// --- Documents ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
SELECT d.id FROM documents d
|
SELECT d.id FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||||
THEN to_tsquery('german', regexp_replace(
|
THEN to_tsquery('simple', regexp_replace(
|
||||||
websearch_to_tsquery('german', :query)::text,
|
websearch_to_tsquery('german', :query)::text,
|
||||||
'''([^'']+)''',
|
'''([^'']+)''',
|
||||||
'''\\1'':*',
|
'''\\1'':*',
|
||||||
@@ -149,7 +149,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
FROM documents d
|
FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||||
THEN to_tsquery('german', regexp_replace(
|
THEN to_tsquery('simple', regexp_replace(
|
||||||
websearch_to_tsquery('german', :query)::text,
|
websearch_to_tsquery('german', :query)::text,
|
||||||
'''([^'']+)''',
|
'''([^'']+)''',
|
||||||
'''\\1'':*',
|
'''\\1'':*',
|
||||||
|
|||||||
@@ -271,6 +271,8 @@ public class DocumentService {
|
|||||||
doc.setTranscription(dto.getTranscription());
|
doc.setTranscription(dto.getTranscription());
|
||||||
doc.setSummary(dto.getSummary());
|
doc.setSummary(dto.getSummary());
|
||||||
doc.setDocumentLocation(dto.getDocumentLocation());
|
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||||
|
doc.setArchiveBox(dto.getArchiveBox());
|
||||||
|
doc.setArchiveFolder(dto.getArchiveFolder());
|
||||||
|
|
||||||
List<String> tags = new ArrayList<>();
|
List<String> tags = new ArrayList<>();
|
||||||
if (dto.getTags() != null && !dto.getTags().isBlank()) {
|
if (dto.getTags() != null && !dto.getTags().isBlank()) {
|
||||||
|
|||||||
@@ -109,8 +109,12 @@ public class PersonService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person createPerson(PersonUpdateDTO dto) {
|
public Person createPerson(PersonUpdateDTO dto) {
|
||||||
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
||||||
|
}
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = Person.builder()
|
Person person = Person.builder()
|
||||||
|
.personType(dto.getPersonType())
|
||||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||||
.firstName(dto.getFirstName())
|
.firstName(dto.getFirstName())
|
||||||
.lastName(dto.getLastName())
|
.lastName(dto.getLastName())
|
||||||
@@ -136,9 +140,13 @@ public class PersonService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||||
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
||||||
|
}
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = personRepository.findById(id)
|
Person person = personRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||||
|
person.setPersonType(dto.getPersonType());
|
||||||
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
||||||
person.setFirstName(dto.getFirstName());
|
person.setFirstName(dto.getFirstName());
|
||||||
person.setLastName(dto.getLastName());
|
person.setLastName(dto.getLastName());
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
@@ -21,10 +23,13 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.stream.Collectors.toSet;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -33,9 +38,10 @@ public class UserService {
|
|||||||
private final AppUserRepository userRepository;
|
private final AppUserRepository userRepository;
|
||||||
private final UserGroupRepository groupRepository;
|
private final UserGroupRepository groupRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
|
||||||
log.info("Creating or updating user: {}", request.getEmail());
|
log.info("Creating or updating user: {}", request.getEmail());
|
||||||
|
|
||||||
Set<UserGroup> groups = new HashSet<>();
|
Set<UserGroup> groups = new HashSet<>();
|
||||||
@@ -45,10 +51,12 @@ public class UserService {
|
|||||||
|
|
||||||
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
||||||
AppUser user;
|
AppUser user;
|
||||||
|
boolean isNew;
|
||||||
|
|
||||||
if (existingUser.isPresent()) {
|
if (existingUser.isPresent()) {
|
||||||
log.info("User exists, updating: {}", request.getEmail());
|
log.info("User exists, updating: {}", request.getEmail());
|
||||||
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||||
|
isNew = false;
|
||||||
} else {
|
} else {
|
||||||
log.info("Creating new user: {}", request.getEmail());
|
log.info("Creating new user: {}", request.getEmail());
|
||||||
user = AppUser.builder()
|
user = AppUser.builder()
|
||||||
@@ -61,8 +69,42 @@ public class UserService {
|
|||||||
.contact(request.getContact())
|
.contact(request.getContact())
|
||||||
.enabled(true)
|
.enabled(true)
|
||||||
.build();
|
.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);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,10 +136,13 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteUser(UUID userId) {
|
public void deleteUser(UUID actorId, UUID userId) {
|
||||||
AppUser user = userRepository.findById(userId)
|
AppUser user = userRepository.findById(userId)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
||||||
|
String email = user.getEmail();
|
||||||
userRepository.delete(user);
|
userRepository.delete(user);
|
||||||
|
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
|
||||||
|
Map.of("userId", userId.toString(), "email", email));
|
||||||
}
|
}
|
||||||
|
|
||||||
public AppUser getById(UUID id) {
|
public AppUser getById(UUID id) {
|
||||||
@@ -141,7 +186,7 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
|
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
|
||||||
AppUser user = getById(id);
|
AppUser user = getById(id);
|
||||||
|
|
||||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||||
@@ -166,13 +211,27 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.getGroupIds() != null) {
|
if (dto.getGroupIds() != null) {
|
||||||
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
Set<UserGroup> before = new HashSet<>(user.getGroups());
|
||||||
user.setGroups(groups);
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
return userRepository.save(user);
|
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
|
@Transactional
|
||||||
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
||||||
AppUser user = getById(userId);
|
AppUser user = getById(userId);
|
||||||
|
|||||||
@@ -6,12 +6,19 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.anyCollection;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -47,4 +54,21 @@ class AuditLogQueryServiceTest {
|
|||||||
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
||||||
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||||
@@ -25,6 +28,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -183,19 +187,19 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Müller\"}"))
|
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +208,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +217,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,11 +229,53 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.firstName").value("Hans"));
|
.andExpect(jsonPath("$.firstName").value("Hans"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns200_forInstitution_withoutFirstName() throws Exception {
|
||||||
|
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
||||||
|
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.lastName").value("Verlag GmbH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_trimsTitle_beforePersisting() throws Exception {
|
||||||
|
ArgumentCaptor<org.raddatz.familienarchiv.dto.PersonUpdateDTO> captor =
|
||||||
|
ArgumentCaptor.forClass(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class);
|
||||||
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
|
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertThat(captor.getValue().getTitle()).isEqualTo("Prof.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns400_whenPersonTypeIsSkip() throws Exception {
|
||||||
|
when(personService.createPerson(any())).thenThrow(
|
||||||
|
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -242,10 +288,10 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +300,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +313,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.lastName").value("Müller"));
|
.andExpect(jsonPath("$.lastName").value("Müller"));
|
||||||
}
|
}
|
||||||
@@ -317,11 +363,10 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
// firstName valid, lastName blank → second || operand = true → 400
|
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +384,7 @@ class PersonControllerTest {
|
|||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||||
"\"notes\":\"Some notes\"}"))
|
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.firstName").value("Maria"))
|
.andExpect(jsonPath("$.firstName").value("Maria"))
|
||||||
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
||||||
@@ -355,7 +400,7 @@ class PersonControllerTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +411,7 @@ class PersonControllerTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +422,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +431,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -104,4 +106,55 @@ class UserControllerTest {
|
|||||||
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.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,6 +179,22 @@ class DocumentFtsTest {
|
|||||||
assertThat(ids).isEmpty();
|
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
|
@Test
|
||||||
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
||||||
documentRepository.saveAndFlush(document("Brief"));
|
documentRepository.saveAndFlush(document("Brief"));
|
||||||
|
|||||||
@@ -121,6 +121,23 @@ class DocumentServiceTest {
|
|||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_setsArchiveBoxAndFolder() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenReturn(doc);
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setArchiveBox("K-03");
|
||||||
|
dto.setArchiveFolder("Mappe B");
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null, null);
|
||||||
|
|
||||||
|
assertThat(doc.getArchiveBox()).isEqualTo("K-03");
|
||||||
|
assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -114,6 +114,43 @@ class PersonServiceTest {
|
|||||||
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── personType + title in createPerson(PersonUpdateDTO) ─────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_persistsPersonType() {
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Walter"); dto.setLastName("de Gruyter"); dto.setPersonType(PersonType.INSTITUTION);
|
||||||
|
|
||||||
|
Person result = personService.createPerson(dto);
|
||||||
|
|
||||||
|
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_throwsInvalidPersonType_whenSkip() {
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.SKIP);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> personService.createPerson(dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_persistsTitle() {
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Dr."); dto.setLastName("Müller"); dto.setTitle("Prof."); dto.setPersonType(PersonType.PERSON);
|
||||||
|
|
||||||
|
Person result = personService.createPerson(dto);
|
||||||
|
|
||||||
|
assertThat(result.getTitle()).isEqualTo("Prof.");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -145,6 +182,36 @@ class PersonServiceTest {
|
|||||||
.isEqualTo(400);
|
.isEqualTo(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── updatePerson (personType) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_throwsInvalidPersonType_whenSkip() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.SKIP);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_persistsPersonType() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").personType(PersonType.PERSON).build();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.INSTITUTION);
|
||||||
|
|
||||||
|
Person result = personService.updatePerson(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
@@ -34,6 +37,7 @@ class UserServiceTest {
|
|||||||
@Mock AppUserRepository userRepository;
|
@Mock AppUserRepository userRepository;
|
||||||
@Mock UserGroupRepository groupRepository;
|
@Mock UserGroupRepository groupRepository;
|
||||||
@Mock PasswordEncoder passwordEncoder;
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
|
@Mock AuditService auditService;
|
||||||
@InjectMocks UserService userService;
|
@InjectMocks UserService userService;
|
||||||
|
|
||||||
// ─── findByEmail ──────────────────────────────────────────────────────────
|
// ─── findByEmail ──────────────────────────────────────────────────────────
|
||||||
@@ -61,7 +65,7 @@ class UserServiceTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.deleteUser(id))
|
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id))
|
||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +75,7 @@ class UserServiceTest {
|
|||||||
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
||||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
userService.deleteUser(id);
|
userService.deleteUser(UUID.randomUUID(), id);
|
||||||
|
|
||||||
verify(userRepository).delete(user);
|
verify(userRepository).delete(user);
|
||||||
}
|
}
|
||||||
@@ -90,7 +94,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
AppUser result = userService.createUserOrUpdate(req);
|
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(userRepository).save(any());
|
verify(userRepository).save(any());
|
||||||
@@ -108,7 +112,7 @@ class UserServiceTest {
|
|||||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||||
when(userRepository.save(any())).thenReturn(existing);
|
when(userRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
userService.createUserOrUpdate(req);
|
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
verify(userRepository, times(1)).save(existing);
|
verify(userRepository, times(1)).save(existing);
|
||||||
}
|
}
|
||||||
@@ -229,7 +233,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getFirstName()).isEqualTo("Ada");
|
assertThat(result.getFirstName()).isEqualTo("Ada");
|
||||||
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
||||||
@@ -246,7 +250,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setFirstName("Ada");
|
dto.setFirstName("Ada");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).containsExactly(adminGroup);
|
assertThat(result.getGroups()).containsExactly(adminGroup);
|
||||||
}
|
}
|
||||||
@@ -264,7 +268,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setGroupIds(List.of(newGroup.getId()));
|
dto.setGroupIds(List.of(newGroup.getId()));
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).containsExactly(newGroup);
|
assertThat(result.getGroups()).containsExactly(newGroup);
|
||||||
}
|
}
|
||||||
@@ -281,7 +285,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setGroupIds(List.of());
|
dto.setGroupIds(List.of());
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).isEmpty();
|
assertThat(result.getGroups()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -313,7 +317,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
AppUser result = userService.createUserOrUpdate(req);
|
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(groupRepository).findAllById(List.of(group.getId()));
|
verify(groupRepository).findAllById(List.of(group.getId()));
|
||||||
@@ -378,7 +382,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setNewPassword("newSecret");
|
dto.setNewPassword("newSecret");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getPassword()).isEqualTo("newHashed");
|
assertThat(result.getPassword()).isEqualTo("newHashed");
|
||||||
}
|
}
|
||||||
@@ -393,7 +397,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setNewPassword(" ");
|
dto.setNewPassword(" ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getPassword()).isEqualTo("original");
|
assertThat(result.getPassword()).isEqualTo("original");
|
||||||
verify(passwordEncoder, never()).encode(any());
|
verify(passwordEncoder, never()).encode(any());
|
||||||
@@ -408,7 +412,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail(" ");
|
dto.setEmail(" ");
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining("blank");
|
.hasMessageContaining("blank");
|
||||||
}
|
}
|
||||||
@@ -425,7 +429,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail("taken@example.com");
|
dto.setEmail("taken@example.com");
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining("E-Mail");
|
.hasMessageContaining("E-Mail");
|
||||||
}
|
}
|
||||||
@@ -497,7 +501,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
userService.createUserOrUpdate(req);
|
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
verify(groupRepository, never()).findAllById(any());
|
verify(groupRepository, never()).findAllById(any());
|
||||||
}
|
}
|
||||||
@@ -561,7 +565,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(null);
|
dto.setContact(null);
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isNull();
|
assertThat(result.getContact()).isNull();
|
||||||
}
|
}
|
||||||
@@ -576,7 +580,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(" ");
|
dto.setContact(" ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isNull();
|
assertThat(result.getContact()).isNull();
|
||||||
}
|
}
|
||||||
@@ -591,7 +595,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(" phone: 555 ");
|
dto.setContact(" phone: 555 ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isEqualTo("phone: 555");
|
assertThat(result.getContact()).isEqualTo("phone: 555");
|
||||||
}
|
}
|
||||||
@@ -606,7 +610,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail(null);
|
dto.setEmail(null);
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
||||||
}
|
}
|
||||||
@@ -622,7 +626,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail("me@example.com");
|
dto.setEmail("me@example.com");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +644,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
userService.createUserOrUpdate(req);
|
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
verify(groupRepository, never()).findAllById(any());
|
verify(groupRepository, never()).findAllById(any());
|
||||||
}
|
}
|
||||||
@@ -699,6 +703,160 @@ class UserServiceTest {
|
|||||||
assertThat(result).containsExactly(g);
|
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 ──────────────────────────────────────────────────────────
|
// ─── createGroup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Briefwechsel",
|
"nav_conversations": "Briefwechsel",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Abmelden",
|
"nav_logout": "Abmelden",
|
||||||
|
"theme_toggle_to_light": "Zu hellem Design wechseln",
|
||||||
|
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
|
||||||
"btn_save": "Speichern",
|
"btn_save": "Speichern",
|
||||||
"btn_cancel": "Abbrechen",
|
"btn_cancel": "Abbrechen",
|
||||||
"btn_confirm": "Bestätigen",
|
"btn_confirm": "Bestätigen",
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||||
"btn_back": "Zurück",
|
"btn_back": "Zurück",
|
||||||
"btn_back_to_document": "Zurück zum Dokument",
|
"btn_back_to_document": "Zurück zum Dokument",
|
||||||
|
"form_label_person_type": "Typ",
|
||||||
|
"form_label_name": "Name",
|
||||||
"form_label_first_name": "Vorname",
|
"form_label_first_name": "Vorname",
|
||||||
"form_label_last_name": "Nachname",
|
"form_label_last_name": "Nachname",
|
||||||
"form_label_alias": "Rufname / Alias",
|
"form_label_alias": "Rufname / Alias",
|
||||||
@@ -527,6 +531,7 @@
|
|||||||
"person_type_INSTITUTION": "Institution",
|
"person_type_INSTITUTION": "Institution",
|
||||||
"person_type_GROUP": "Gruppe",
|
"person_type_GROUP": "Gruppe",
|
||||||
"person_type_UNKNOWN": "Unbekannt",
|
"person_type_UNKNOWN": "Unbekannt",
|
||||||
|
"a11y_type_changed": "Typ geändert zu {type}",
|
||||||
"person_alias_add_heading": "Name hinzufuegen",
|
"person_alias_add_heading": "Name hinzufuegen",
|
||||||
"person_alias_label_type": "Art",
|
"person_alias_label_type": "Art",
|
||||||
"person_alias_label_last_name": "Nachname",
|
"person_alias_label_last_name": "Nachname",
|
||||||
@@ -536,6 +541,9 @@
|
|||||||
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
||||||
"person_alias_btn_delete": "Entfernen",
|
"person_alias_btn_delete": "Entfernen",
|
||||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
"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_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||||
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
|
"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.",
|
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Letters",
|
"nav_conversations": "Letters",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Sign out",
|
"nav_logout": "Sign out",
|
||||||
|
"theme_toggle_to_light": "Switch to light mode",
|
||||||
|
"theme_toggle_to_dark": "Switch to dark mode",
|
||||||
"btn_save": "Save",
|
"btn_save": "Save",
|
||||||
"btn_cancel": "Cancel",
|
"btn_cancel": "Cancel",
|
||||||
"btn_confirm": "Confirm",
|
"btn_confirm": "Confirm",
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
"btn_back_to_overview": "Back to overview",
|
"btn_back_to_overview": "Back to overview",
|
||||||
"btn_back": "Back",
|
"btn_back": "Back",
|
||||||
"btn_back_to_document": "Back to document",
|
"btn_back_to_document": "Back to document",
|
||||||
|
"form_label_person_type": "Type",
|
||||||
|
"form_label_name": "Name",
|
||||||
"form_label_first_name": "First name",
|
"form_label_first_name": "First name",
|
||||||
"form_label_last_name": "Last name",
|
"form_label_last_name": "Last name",
|
||||||
"form_label_alias": "Nickname / Alias",
|
"form_label_alias": "Nickname / Alias",
|
||||||
@@ -527,6 +531,7 @@
|
|||||||
"person_type_INSTITUTION": "Institution",
|
"person_type_INSTITUTION": "Institution",
|
||||||
"person_type_GROUP": "Group",
|
"person_type_GROUP": "Group",
|
||||||
"person_type_UNKNOWN": "Unknown",
|
"person_type_UNKNOWN": "Unknown",
|
||||||
|
"a11y_type_changed": "Type changed to {type}",
|
||||||
"person_alias_add_heading": "Add name",
|
"person_alias_add_heading": "Add name",
|
||||||
"person_alias_label_type": "Type",
|
"person_alias_label_type": "Type",
|
||||||
"person_alias_label_last_name": "Last name",
|
"person_alias_label_last_name": "Last name",
|
||||||
@@ -536,6 +541,9 @@
|
|||||||
"person_alias_delete_body": "This name will be removed from search results.",
|
"person_alias_delete_body": "This name will be removed from search results.",
|
||||||
"person_alias_btn_delete": "Remove",
|
"person_alias_btn_delete": "Remove",
|
||||||
"error_alias_not_found": "The name alias was not found.",
|
"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_service_unavailable": "The OCR service is not available.",
|
||||||
"error_ocr_job_not_found": "The OCR job was not found.",
|
"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.",
|
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Cartas",
|
"nav_conversations": "Cartas",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Cerrar sesión",
|
"nav_logout": "Cerrar sesión",
|
||||||
|
"theme_toggle_to_light": "Cambiar a modo claro",
|
||||||
|
"theme_toggle_to_dark": "Cambiar a modo oscuro",
|
||||||
"btn_save": "Guardar",
|
"btn_save": "Guardar",
|
||||||
"btn_cancel": "Cancelar",
|
"btn_cancel": "Cancelar",
|
||||||
"btn_confirm": "Confirmar",
|
"btn_confirm": "Confirmar",
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
"btn_back_to_overview": "Volver al resumen",
|
"btn_back_to_overview": "Volver al resumen",
|
||||||
"btn_back": "Volver",
|
"btn_back": "Volver",
|
||||||
"btn_back_to_document": "Volver al documento",
|
"btn_back_to_document": "Volver al documento",
|
||||||
|
"form_label_person_type": "Tipo",
|
||||||
|
"form_label_name": "Nombre",
|
||||||
"form_label_first_name": "Nombre",
|
"form_label_first_name": "Nombre",
|
||||||
"form_label_last_name": "Apellido",
|
"form_label_last_name": "Apellido",
|
||||||
"form_label_alias": "Apodo / Alias",
|
"form_label_alias": "Apodo / Alias",
|
||||||
@@ -527,6 +531,7 @@
|
|||||||
"person_type_INSTITUTION": "Institución",
|
"person_type_INSTITUTION": "Institución",
|
||||||
"person_type_GROUP": "Grupo",
|
"person_type_GROUP": "Grupo",
|
||||||
"person_type_UNKNOWN": "Desconocido",
|
"person_type_UNKNOWN": "Desconocido",
|
||||||
|
"a11y_type_changed": "Tipo cambiado a {type}",
|
||||||
"person_alias_add_heading": "Agregar nombre",
|
"person_alias_add_heading": "Agregar nombre",
|
||||||
"person_alias_label_type": "Tipo",
|
"person_alias_label_type": "Tipo",
|
||||||
"person_alias_label_last_name": "Apellido",
|
"person_alias_label_last_name": "Apellido",
|
||||||
@@ -536,6 +541,9 @@
|
|||||||
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
||||||
"person_alias_btn_delete": "Eliminar",
|
"person_alias_btn_delete": "Eliminar",
|
||||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
"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_service_unavailable": "El servicio OCR no está disponible.",
|
||||||
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
|
"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.",
|
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
|
||||||
|
|||||||
87
frontend/src/lib/actions/radioGroupNav.svelte.spec.ts
Normal file
87
frontend/src/lib/actions/radioGroupNav.svelte.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
const { radioGroupNav } = await import('./radioGroupNav');
|
||||||
|
|
||||||
|
describe('radioGroupNav action', () => {
|
||||||
|
const nodes: HTMLElement[] = [];
|
||||||
|
|
||||||
|
function makeGroup(count: number): { container: HTMLElement; buttons: HTMLElement[] } {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.setAttribute('role', 'radiogroup');
|
||||||
|
const buttons: HTMLElement[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.setAttribute('role', 'radio');
|
||||||
|
btn.setAttribute('aria-checked', i === 0 ? 'true' : 'false');
|
||||||
|
btn.setAttribute('tabindex', i === 0 ? '0' : '-1');
|
||||||
|
container.appendChild(btn);
|
||||||
|
buttons.push(btn);
|
||||||
|
}
|
||||||
|
document.body.appendChild(container);
|
||||||
|
nodes.push(container);
|
||||||
|
return { container, buttons };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nodes.forEach((n) => n.remove());
|
||||||
|
nodes.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowRight moves focus to next button', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[0].focus();
|
||||||
|
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowRight wraps from last to first', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[3].focus();
|
||||||
|
buttons[3].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowLeft moves focus to previous button', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[2].focus();
|
||||||
|
buttons[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowLeft wraps from first to last', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[0].focus();
|
||||||
|
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ArrowRight updates aria-checked on new button and removes it from old', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[0].focus();
|
||||||
|
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
expect(buttons[1].getAttribute('aria-checked')).toBe('true');
|
||||||
|
expect(buttons[0].getAttribute('aria-checked')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroy removes keydown listener', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
const { destroy } = radioGroupNav(container);
|
||||||
|
destroy();
|
||||||
|
buttons[0].focus();
|
||||||
|
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-arrow keys', () => {
|
||||||
|
const { container, buttons } = makeGroup(4);
|
||||||
|
radioGroupNav(container);
|
||||||
|
buttons[0].focus();
|
||||||
|
buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
expect(document.activeElement).toBe(buttons[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
frontend/src/lib/actions/radioGroupNav.ts
Normal file
37
frontend/src/lib/actions/radioGroupNav.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export function radioGroupNav(
|
||||||
|
node: HTMLElement,
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
): { destroy: () => void; update: (onChange?: (value: string) => void) => void } {
|
||||||
|
let onChangeFn = onChange;
|
||||||
|
|
||||||
|
function getRadios(): HTMLElement[] {
|
||||||
|
return Array.from(node.querySelectorAll<HTMLElement>('[role="radio"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
|
||||||
|
|
||||||
|
const radios = getRadios();
|
||||||
|
const current = radios.indexOf(document.activeElement as HTMLElement);
|
||||||
|
if (current === -1) return;
|
||||||
|
|
||||||
|
const delta = event.key === 'ArrowRight' ? 1 : -1;
|
||||||
|
const next = (current + delta + radios.length) % radios.length;
|
||||||
|
|
||||||
|
radios[current].setAttribute('aria-checked', 'false');
|
||||||
|
radios[next].setAttribute('aria-checked', 'true');
|
||||||
|
radios[next].focus();
|
||||||
|
onChangeFn?.(radios[next].getAttribute('value') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(newOnChange) {
|
||||||
|
onChangeFn = newOnChange;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('keydown', handleKeydown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -48,6 +48,12 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bellLabel = $derived(
|
||||||
|
stream.unreadCount > 0
|
||||||
|
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||||
|
: m.notification_bell_label()
|
||||||
|
);
|
||||||
|
|
||||||
function attachBellButton(node: HTMLButtonElement) {
|
function attachBellButton(node: HTMLButtonElement) {
|
||||||
bellButtonEl = node;
|
bellButtonEl = node;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -72,12 +78,11 @@ onDestroy(() => {
|
|||||||
{@attach attachBellButton}
|
{@attach attachBellButton}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleDropdown}
|
onclick={toggleDropdown}
|
||||||
aria-label={stream.unreadCount > 0
|
aria-label={bellLabel}
|
||||||
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
title={bellLabel}
|
||||||
: m.notification_bell_label()}
|
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -55,6 +55,34 @@ async function openDropdownAndClickFirstNotification() {
|
|||||||
notifButton.click();
|
notifButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('NotificationBell — cursor and tooltip', () => {
|
||||||
|
it('bell button has cursor-pointer class', async () => {
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.classList.contains('cursor-pointer')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bell button title equals aria-label when unreadCount is 0', async () => {
|
||||||
|
mockNotificationList.value = [];
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bell button title equals aria-label when unreadCount is 3', async () => {
|
||||||
|
mockNotificationList.value = [
|
||||||
|
makeNotification({ id: 'n1' }),
|
||||||
|
makeNotification({ id: 'n2' }),
|
||||||
|
makeNotification({ id: 'n3' })
|
||||||
|
];
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('NotificationBell', () => {
|
describe('NotificationBell', () => {
|
||||||
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
||||||
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
||||||
|
|||||||
58
frontend/src/lib/components/PersonTypeSelector.svelte
Normal file
58
frontend/src/lib/components/PersonTypeSelector.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { radioGroupNav } from '$lib/actions/radioGroupNav';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = 'PERSON',
|
||||||
|
name = 'personType',
|
||||||
|
onchange
|
||||||
|
}: { value?: string; name?: string; onchange?: (type: PersonType) => void } = $props();
|
||||||
|
|
||||||
|
let selected = $state<PersonType>(
|
||||||
|
untrack(() => (TYPES.includes(value as PersonType) ? (value as PersonType) : 'PERSON'))
|
||||||
|
);
|
||||||
|
|
||||||
|
let announcement = $state('');
|
||||||
|
|
||||||
|
const labels: Record<PersonType, () => string> = {
|
||||||
|
PERSON: m.person_type_PERSON,
|
||||||
|
INSTITUTION: m.person_type_INSTITUTION,
|
||||||
|
GROUP: m.person_type_GROUP,
|
||||||
|
UNKNOWN: m.person_type_UNKNOWN
|
||||||
|
};
|
||||||
|
|
||||||
|
function select(type: PersonType) {
|
||||||
|
selected = type;
|
||||||
|
announcement = m.a11y_type_changed({ type: labels[type]() });
|
||||||
|
onchange?.(type);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={m.form_label_person_type()}
|
||||||
|
class="grid grid-cols-2 gap-2 sm:grid-cols-4"
|
||||||
|
use:radioGroupNav={(v) => { if (TYPES.includes(v as PersonType)) select(v as PersonType); }}
|
||||||
|
>
|
||||||
|
{#each TYPES as type (type)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
value={type}
|
||||||
|
aria-checked={selected === type}
|
||||||
|
tabindex={selected === type ? 0 : -1}
|
||||||
|
onclick={() => select(type)}
|
||||||
|
class="min-h-[48px] cursor-pointer rounded-sm border px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none {selected === type
|
||||||
|
? 'border-primary bg-primary text-primary-fg'
|
||||||
|
: 'border-line bg-surface text-ink hover:border-primary/50'}"
|
||||||
|
>
|
||||||
|
{labels[type]()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name={name} value={selected} />
|
||||||
|
|
||||||
|
<div class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</div>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { userEvent } from 'vitest/browser';
|
||||||
|
|
||||||
|
import PersonTypeSelector from './PersonTypeSelector.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('PersonTypeSelector', () => {
|
||||||
|
it('radiogroup has an accessible name via aria-label', () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const radiogroup = container.querySelector('[role="radiogroup"]');
|
||||||
|
expect(radiogroup).not.toBeNull();
|
||||||
|
expect(radiogroup!.getAttribute('aria-label')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden input value updates when user navigates with ArrowRight', async () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||||
|
expect(hiddenInput.value).toBe('PERSON');
|
||||||
|
|
||||||
|
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
||||||
|
personButton.focus();
|
||||||
|
await userEvent.keyboard('{ArrowRight}');
|
||||||
|
|
||||||
|
expect(hiddenInput.value).toBe('INSTITUTION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden input value updates when user navigates with ArrowLeft (wraps around)', async () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const hiddenInput = container.querySelector('input[type="hidden"]') as HTMLInputElement;
|
||||||
|
expect(hiddenInput.value).toBe('PERSON');
|
||||||
|
|
||||||
|
const personButton = container.querySelector('[aria-checked="true"]') as HTMLElement;
|
||||||
|
personButton.focus();
|
||||||
|
await userEvent.keyboard('{ArrowLeft}');
|
||||||
|
|
||||||
|
expect(hiddenInput.value).toBe('UNKNOWN');
|
||||||
|
});
|
||||||
|
it('exactly one button is aria-checked=true for the initial value', () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'INSTITUTION' });
|
||||||
|
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||||
|
const checked = buttons.filter((b) => b.getAttribute('aria-checked') === 'true');
|
||||||
|
const unchecked = buttons.filter((b) => b.getAttribute('aria-checked') === 'false');
|
||||||
|
expect(checked).toHaveLength(1);
|
||||||
|
expect(unchecked).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-checked=true moves to clicked button on click', async () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||||
|
const groupButton = buttons.find((b) => b.getAttribute('value') === 'GROUP') as HTMLElement;
|
||||||
|
await userEvent.click(groupButton);
|
||||||
|
expect(groupButton.getAttribute('aria-checked')).toBe('true');
|
||||||
|
const others = buttons.filter((b) => b !== groupButton);
|
||||||
|
for (const btn of others) {
|
||||||
|
expect(btn.getAttribute('aria-checked')).toBe('false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selected button has tabindex=0, unselected buttons have tabindex=-1', () => {
|
||||||
|
const { container } = render(PersonTypeSelector, { value: 'PERSON' });
|
||||||
|
const buttons = Array.from(container.querySelectorAll('[role="radio"]'));
|
||||||
|
const selected = buttons.find((b) => b.getAttribute('aria-checked') === 'true');
|
||||||
|
const unselected = buttons.filter((b) => b.getAttribute('aria-checked') !== 'true');
|
||||||
|
expect(selected!.getAttribute('tabindex')).toBe('0');
|
||||||
|
for (const btn of unselected) {
|
||||||
|
expect(btn.getAttribute('tabindex')).toBe('-1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ onMount(() => {
|
|||||||
theme = resolveInitialTheme();
|
theme = resolveInitialTheme();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const themeLabel = $derived(
|
||||||
|
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
|
||||||
|
);
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
theme = theme === 'dark' ? 'light' : 'dark';
|
theme = theme === 'dark' ? 'light' : 'dark';
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
@@ -29,8 +34,8 @@ function toggle() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggle}
|
onclick={toggle}
|
||||||
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
aria-label={themeLabel}
|
||||||
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
title={themeLabel}
|
||||||
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
{#if theme === 'dark'}
|
{#if theme === 'dark'}
|
||||||
|
|||||||
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import ThemeToggle from './ThemeToggle.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.removeItem('theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle — label derivation (light mode)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label invites switching to dark mode when theme is light', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title equals aria-label in light mode', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle — label derivation (dark mode)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label invites switching to light mode when theme is dark', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title equals aria-label in dark mode', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,7 +67,6 @@ let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
|
|||||||
let dateIso = $state('');
|
let dateIso = $state('');
|
||||||
let tags = $state<Tag[]>([]);
|
let tags = $state<Tag[]>([]);
|
||||||
// Bulk-edit only — replace-on-non-blank semantics.
|
// Bulk-edit only — replace-on-non-blank semantics.
|
||||||
let documentLocation = $state('');
|
|
||||||
let archiveBox = $state('');
|
let archiveBox = $state('');
|
||||||
let archiveFolder = $state('');
|
let archiveFolder = $state('');
|
||||||
|
|
||||||
@@ -231,7 +230,6 @@ async function saveBulkEdit() {
|
|||||||
tagNames: tags.map((t) => t.name),
|
tagNames: tags.map((t) => t.name),
|
||||||
senderId: senderId || null,
|
senderId: senderId || null,
|
||||||
receiverIds: selectedReceivers.map((r) => r.id),
|
receiverIds: selectedReceivers.map((r) => r.id),
|
||||||
documentLocation: documentLocation || null,
|
|
||||||
archiveBox: archiveBox || null,
|
archiveBox: archiveBox || null,
|
||||||
archiveFolder: archiveFolder || null
|
archiveFolder: archiveFolder || null
|
||||||
};
|
};
|
||||||
@@ -442,7 +440,6 @@ async function retrySave() {
|
|||||||
/>
|
/>
|
||||||
<DescriptionSection
|
<DescriptionSection
|
||||||
bind:tags={tags}
|
bind:tags={tags}
|
||||||
bind:documentLocation={documentLocation}
|
|
||||||
bind:archiveBox={archiveBox}
|
bind:archiveBox={archiveBox}
|
||||||
bind:archiveFolder={archiveFolder}
|
bind:archiveFolder={archiveFolder}
|
||||||
hideTitle
|
hideTitle
|
||||||
@@ -494,7 +491,6 @@ async function retrySave() {
|
|||||||
/>
|
/>
|
||||||
<DescriptionSection
|
<DescriptionSection
|
||||||
bind:tags={tags}
|
bind:tags={tags}
|
||||||
bind:documentLocation={documentLocation}
|
|
||||||
bind:archiveBox={archiveBox}
|
bind:archiveBox={archiveBox}
|
||||||
bind:archiveFolder={archiveFolder}
|
bind:archiveFolder={archiveFolder}
|
||||||
hideTitle
|
hideTitle
|
||||||
|
|||||||
@@ -397,8 +397,8 @@ describe('BulkDocumentEditLayout — mode="edit"', () => {
|
|||||||
initialEditEntries: [editEntry(1)]
|
initialEditEntries: [editEntry(1)]
|
||||||
});
|
});
|
||||||
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
|
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
|
||||||
// sender + documentLocation + archiveBox + archiveFolder = 4
|
// sender + archiveBox + archiveFolder = 3
|
||||||
expect(replaceBadges.length).toBeGreaterThanOrEqual(4);
|
expect(replaceBadges.length).toBeGreaterThanOrEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => {
|
it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ let {
|
|||||||
archiveBox = $bindable(''),
|
archiveBox = $bindable(''),
|
||||||
archiveFolder = $bindable(''),
|
archiveFolder = $bindable(''),
|
||||||
initialTitle = '',
|
initialTitle = '',
|
||||||
initialDocumentLocation = '',
|
initialArchiveBox = '',
|
||||||
|
initialArchiveFolder = '',
|
||||||
initialSummary = '',
|
initialSummary = '',
|
||||||
titleRequired = false,
|
titleRequired = false,
|
||||||
suggestedTitle = '',
|
suggestedTitle = '',
|
||||||
@@ -24,7 +25,8 @@ let {
|
|||||||
archiveBox?: string;
|
archiveBox?: string;
|
||||||
archiveFolder?: string;
|
archiveFolder?: string;
|
||||||
initialTitle?: string;
|
initialTitle?: string;
|
||||||
initialDocumentLocation?: string;
|
initialArchiveBox?: string;
|
||||||
|
initialArchiveFolder?: string;
|
||||||
initialSummary?: string;
|
initialSummary?: string;
|
||||||
titleRequired?: boolean;
|
titleRequired?: boolean;
|
||||||
suggestedTitle?: string;
|
suggestedTitle?: string;
|
||||||
@@ -41,7 +43,8 @@ let {
|
|||||||
let titleDirty = $state(false);
|
let titleDirty = $state(false);
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!currentTitle && initialTitle) currentTitle = initialTitle;
|
if (!currentTitle && initialTitle) currentTitle = initialTitle;
|
||||||
if (!documentLocation && initialDocumentLocation) documentLocation = initialDocumentLocation;
|
if (!archiveBox && initialArchiveBox) archiveBox = initialArchiveBox;
|
||||||
|
if (!archiveFolder && initialArchiveFolder) archiveFolder = initialArchiveFolder;
|
||||||
});
|
});
|
||||||
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
|
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
|
||||||
</script>
|
</script>
|
||||||
@@ -110,55 +113,36 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Aufbewahrungsort (optional) -->
|
<!-- Karton -->
|
||||||
<div data-testid="description-document-location">
|
<div data-testid="description-archive-box">
|
||||||
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
|
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
>{m.form_label_archive_location()}
|
{m.form_label_archive_box()}
|
||||||
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="documentLocation"
|
id="archiveBox"
|
||||||
type="text"
|
type="text"
|
||||||
name="documentLocation"
|
name="archiveBox"
|
||||||
bind:value={documentLocation}
|
bind:value={archiveBox}
|
||||||
placeholder={m.form_placeholder_archive_location()}
|
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if editMode}
|
<!-- Mappe -->
|
||||||
<!-- Karton (only in editMode — bulk-editable replace) -->
|
<div data-testid="description-archive-folder">
|
||||||
<div data-testid="description-archive-box">
|
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
|
{m.form_label_archive_folder()}
|
||||||
{m.form_label_archive_box()}
|
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
||||||
<FieldLabelBadge variant="replace" />
|
</label>
|
||||||
</label>
|
<input
|
||||||
<input
|
id="archiveFolder"
|
||||||
id="archiveBox"
|
type="text"
|
||||||
type="text"
|
name="archiveFolder"
|
||||||
name="archiveBox"
|
bind:value={archiveFolder}
|
||||||
bind:value={archiveBox}
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
/>
|
||||||
/>
|
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
|
||||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mappe (only in editMode — bulk-editable replace) -->
|
|
||||||
<div data-testid="description-archive-folder">
|
|
||||||
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
|
|
||||||
{m.form_label_archive_folder()}
|
|
||||||
<FieldLabelBadge variant="replace" />
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="archiveFolder"
|
|
||||||
type="text"
|
|
||||||
name="archiveFolder"
|
|
||||||
bind:value={archiveFolder}
|
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
|
||||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,19 +21,10 @@ describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fen
|
|||||||
expect(titleInput.value).toBe('Parent Title');
|
expect(titleInput.value).toBe('Parent Title');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pre-fills the documentLocation input from initialDocumentLocation', async () => {
|
it('always renders archiveBox + archiveFolder fields regardless of editMode', async () => {
|
||||||
render(DescriptionSection, { initialDocumentLocation: 'Schrank 3, Mappe B' });
|
render(DescriptionSection, { editMode: false });
|
||||||
const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement;
|
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
|
||||||
expect(locationInput.value).toBe('Schrank 3, Mappe B');
|
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||||
});
|
|
||||||
|
|
||||||
it('does not stomp a parent-bound documentLocation that is already non-empty', async () => {
|
|
||||||
render(DescriptionSection, {
|
|
||||||
documentLocation: 'Bound Value',
|
|
||||||
initialDocumentLocation: 'Should Not Win'
|
|
||||||
});
|
|
||||||
const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement;
|
|
||||||
expect(locationInput.value).toBe('Bound Value');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the editMode-only archiveBox + archiveFolder fields when editMode=true', async () => {
|
it('renders the editMode-only archiveBox + archiveFolder fields when editMode=true', async () => {
|
||||||
@@ -42,9 +33,25 @@ describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fen
|
|||||||
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides the editMode-only archive fields when editMode=false', async () => {
|
it('pre-fills archiveBox from initialArchiveBox when archiveBox is empty', async () => {
|
||||||
render(DescriptionSection, { editMode: false });
|
render(DescriptionSection, { initialArchiveBox: 'K-03', hideTitle: true });
|
||||||
expect(document.querySelector('[data-testid="description-archive-box"]')).toBeNull();
|
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
|
||||||
expect(document.querySelector('[data-testid="description-archive-folder"]')).toBeNull();
|
expect(input.value).toBe('K-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-fills archiveFolder from initialArchiveFolder when archiveFolder is empty', async () => {
|
||||||
|
render(DescriptionSection, { initialArchiveFolder: 'Mappe B', hideTitle: true });
|
||||||
|
const input = document.querySelector('input#archiveFolder') as HTMLInputElement;
|
||||||
|
expect(input.value).toBe('Mappe B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not stomp a parent-bound archiveBox that is already non-empty', async () => {
|
||||||
|
render(DescriptionSection, {
|
||||||
|
archiveBox: 'Parent Value',
|
||||||
|
initialArchiveBox: 'Should Not Win',
|
||||||
|
hideTitle: true
|
||||||
|
});
|
||||||
|
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
|
||||||
|
expect(input.value).toBe('Parent Value');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -207,7 +207,8 @@ async function handleReplaceFile(e: Event) {
|
|||||||
bind:tags={tags}
|
bind:tags={tags}
|
||||||
bind:currentTitle={currentTitle}
|
bind:currentTitle={currentTitle}
|
||||||
initialTitle={doc.title ?? ''}
|
initialTitle={doc.title ?? ''}
|
||||||
initialDocumentLocation={doc.documentLocation ?? ''}
|
initialArchiveBox={doc.archiveBox ?? ''}
|
||||||
|
initialArchiveFolder={doc.archiveFolder ?? ''}
|
||||||
initialSummary={doc.summary ?? ''}
|
initialSummary={doc.summary ?? ''}
|
||||||
titleRequired={true}
|
titleRequired={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as m from '$lib/paraglide/messages.js';
|
|||||||
export type ErrorCode =
|
export type ErrorCode =
|
||||||
| 'PERSON_NOT_FOUND'
|
| 'PERSON_NOT_FOUND'
|
||||||
| 'ALIAS_NOT_FOUND'
|
| 'ALIAS_NOT_FOUND'
|
||||||
|
| 'INVALID_PERSON_TYPE'
|
||||||
| 'DOCUMENT_NOT_FOUND'
|
| 'DOCUMENT_NOT_FOUND'
|
||||||
| 'DOCUMENT_NO_FILE'
|
| 'DOCUMENT_NO_FILE'
|
||||||
| 'FILE_NOT_FOUND'
|
| 'FILE_NOT_FOUND'
|
||||||
@@ -73,6 +74,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_person_not_found();
|
return m.error_person_not_found();
|
||||||
case 'ALIAS_NOT_FOUND':
|
case 'ALIAS_NOT_FOUND':
|
||||||
return m.error_alias_not_found();
|
return m.error_alias_not_found();
|
||||||
|
case 'INVALID_PERSON_TYPE':
|
||||||
|
return m.error_invalid_person_type();
|
||||||
case 'DOCUMENT_NOT_FOUND':
|
case 'DOCUMENT_NOT_FOUND':
|
||||||
return m.error_document_not_found();
|
return m.error_document_not_found();
|
||||||
case 'DOCUMENT_NO_FILE':
|
case 'DOCUMENT_NO_FILE':
|
||||||
|
|||||||
40
frontend/src/lib/person-validation.test.ts
Normal file
40
frontend/src/lib/person-validation.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { validatePersonFields } from './person-validation';
|
||||||
|
|
||||||
|
describe('validatePersonFields', () => {
|
||||||
|
it('returns null when all required fields are present for PERSON', () => {
|
||||||
|
expect(validatePersonFields('PERSON', 'Hans', 'Müller')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns lastName error key when lastName is missing', () => {
|
||||||
|
expect(validatePersonFields('PERSON', 'Hans', '')).toBe('validation_last_name_required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns lastName error key when lastName is undefined', () => {
|
||||||
|
expect(validatePersonFields('INSTITUTION', undefined, undefined)).toBe(
|
||||||
|
'validation_last_name_required'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns firstName error key when type is PERSON and firstName is missing', () => {
|
||||||
|
expect(validatePersonFields('PERSON', '', 'Müller')).toBe('validation_first_name_required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns firstName error key when type is PERSON and firstName is undefined', () => {
|
||||||
|
expect(validatePersonFields('PERSON', undefined, 'Müller')).toBe(
|
||||||
|
'validation_first_name_required'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for INSTITUTION without firstName', () => {
|
||||||
|
expect(validatePersonFields('INSTITUTION', undefined, 'Verlag GmbH')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for GROUP without firstName', () => {
|
||||||
|
expect(validatePersonFields('GROUP', undefined, 'Familie Raddatz')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for UNKNOWN without firstName', () => {
|
||||||
|
expect(validatePersonFields('UNKNOWN', undefined, 'Unbekannt')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
39
frontend/src/lib/person-validation.ts
Normal file
39
frontend/src/lib/person-validation.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
export const PERSON_TYPES = ['PERSON', 'INSTITUTION', 'GROUP', 'UNKNOWN'] as const;
|
||||||
|
export type PersonType = (typeof PERSON_TYPES)[number];
|
||||||
|
|
||||||
|
export type PersonFormData = {
|
||||||
|
personType?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName: string;
|
||||||
|
alias?: string | null;
|
||||||
|
birthYear?: number | null;
|
||||||
|
deathYear?: number | null;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizePersonType(raw: string | undefined | null): PersonType {
|
||||||
|
return raw === 'SKIP' ? 'UNKNOWN' : ((raw ?? 'PERSON') as PersonType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonValidationKey =
|
||||||
|
| 'validation_last_name_required'
|
||||||
|
| 'validation_first_name_required';
|
||||||
|
|
||||||
|
export function resolveValidationMessage(key: PersonValidationKey): string {
|
||||||
|
return key === 'validation_last_name_required'
|
||||||
|
? m.validation_last_name_required()
|
||||||
|
: m.validation_first_name_required();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePersonFields(
|
||||||
|
personType: string,
|
||||||
|
firstName: string | undefined | null,
|
||||||
|
lastName: string | undefined | null
|
||||||
|
): PersonValidationKey | null {
|
||||||
|
if (!lastName) return 'validation_last_name_required';
|
||||||
|
if (personType === 'PERSON' && !firstName) return 'validation_first_name_required';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -13,14 +13,12 @@ let {
|
|||||||
items,
|
items,
|
||||||
canWrite,
|
canWrite,
|
||||||
error,
|
error,
|
||||||
total = 0,
|
|
||||||
q = '',
|
q = '',
|
||||||
sort = 'DATE'
|
sort = 'DATE'
|
||||||
}: {
|
}: {
|
||||||
items: DocumentSearchItem[];
|
items: DocumentSearchItem[];
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
total?: number;
|
|
||||||
q?: string;
|
q?: string;
|
||||||
sort?: SortMode;
|
sort?: SortMode;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -71,29 +69,6 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- DOCUMENT LIST HEADER -->
|
|
||||||
<div class="mb-2 flex justify-end">
|
|
||||||
{#if canWrite}
|
|
||||||
<a
|
|
||||||
href="/documents/new"
|
|
||||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
{m.docs_btn_new()}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RESULT COUNT -->
|
|
||||||
{#if total > 0}
|
|
||||||
<p class="mb-3 font-sans text-base text-ink-2">{m.docs_result_count({ count: total })}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- ERROR -->
|
<!-- ERROR -->
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="border border-line bg-surface shadow-sm">
|
<div class="border border-line bg-surface shadow-sm">
|
||||||
|
|||||||
@@ -234,28 +234,54 @@ $effect(() => {
|
|||||||
onblur={() => (qFocused = false)}
|
onblur={() => (qFocused = false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if data.canWrite && data.totalElements > 0}
|
<div class="mb-3 flex items-center justify-between gap-4">
|
||||||
<div class="mb-2 flex flex-col items-end gap-1">
|
<p class="font-sans text-base text-ink-2">
|
||||||
<button
|
{#if data.totalElements > 0}{m.docs_result_count({ count: data.totalElements })}{/if}
|
||||||
type="button"
|
</p>
|
||||||
onclick={editAllMatching}
|
{#if data.canWrite}
|
||||||
disabled={editingAll}
|
<div class="flex flex-col items-end gap-1">
|
||||||
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
|
<div class="flex items-center gap-4">
|
||||||
data-testid="bulk-edit-all-x"
|
{#if data.totalElements > 0}
|
||||||
>
|
<button
|
||||||
{m.bulk_edit_all_x({ count: data.totalElements })}
|
type="button"
|
||||||
</button>
|
onclick={editAllMatching}
|
||||||
{#if editAllError}
|
disabled={editingAll}
|
||||||
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
class="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
|
||||||
{editAllError}
|
data-testid="bulk-edit-all-x"
|
||||||
</p>
|
>
|
||||||
{/if}
|
<img
|
||||||
</div>
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||||
{/if}
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{m.bulk_edit_all_x({ count: data.totalElements })}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
href="/documents/new"
|
||||||
|
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Add/Add-General-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{m.docs_btn_new()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{#if editAllError}
|
||||||
|
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
|
||||||
|
{editAllError}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DocumentList
|
<DocumentList
|
||||||
items={data.items}
|
items={data.items}
|
||||||
total={data.totalElements}
|
|
||||||
q={data.q}
|
q={data.q}
|
||||||
canWrite={data.canWrite}
|
canWrite={data.canWrite}
|
||||||
error={data.error}
|
error={data.error}
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ export const actions = {
|
|||||||
if (doc.title) formData.set('title', doc.title);
|
if (doc.title) formData.set('title', doc.title);
|
||||||
if (doc.documentDate) formData.set('documentDate', doc.documentDate);
|
if (doc.documentDate) formData.set('documentDate', doc.documentDate);
|
||||||
if (doc.location) formData.set('location', doc.location);
|
if (doc.location) formData.set('location', doc.location);
|
||||||
if (doc.documentLocation) formData.set('documentLocation', doc.documentLocation);
|
if (doc.archiveBox) formData.set('archiveBox', doc.archiveBox);
|
||||||
|
if (doc.archiveFolder) formData.set('archiveFolder', doc.archiveFolder);
|
||||||
if (doc.transcription) formData.set('transcription', doc.transcription);
|
if (doc.transcription) formData.set('transcription', doc.transcription);
|
||||||
if (doc.summary) formData.set('summary', doc.summary);
|
if (doc.summary) formData.set('summary', doc.summary);
|
||||||
if (doc.sender?.id) formData.set('senderId', doc.sender.id);
|
if (doc.sender?.id) formData.set('senderId', doc.sender.id);
|
||||||
|
|||||||
@@ -365,6 +365,11 @@
|
|||||||
text-underline-offset: 4px;
|
text-underline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tailwind preflight resets cursor on *, overriding the browser default for buttons */
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
|
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--c-focus-ring);
|
outline: 2px solid var(--c-focus-ring);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ let {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
personType?: string | null;
|
personType?: string | null;
|
||||||
|
title?: string | null;
|
||||||
alias?: string | null;
|
alias?: string | null;
|
||||||
birthYear?: number | null;
|
birthYear?: number | null;
|
||||||
deathYear?: number | null;
|
deathYear?: number | null;
|
||||||
@@ -66,6 +67,14 @@ let {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if person.personType === 'PERSON' && person.title}
|
||||||
|
<p
|
||||||
|
class="mb-0.5 text-center font-sans text-xs tracking-widest text-ink-3 [font-variant:small-caps]"
|
||||||
|
>
|
||||||
|
{person.title}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Name — centered, serif -->
|
<!-- Name — centered, serif -->
|
||||||
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
|
<h1 class="mb-1 text-center font-serif text-xl font-bold text-ink">
|
||||||
{person.displayName}
|
{person.displayName}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import {
|
||||||
|
normalizePersonType,
|
||||||
|
validatePersonFields,
|
||||||
|
resolveValidationMessage
|
||||||
|
} from '$lib/person-validation';
|
||||||
|
|
||||||
export async function load({ params, fetch, locals }) {
|
export async function load({ params, fetch, locals }) {
|
||||||
const canWrite =
|
const canWrite =
|
||||||
@@ -22,12 +27,16 @@ export async function load({ params, fetch, locals }) {
|
|||||||
throw error(result.response.status, getErrorMessage(code));
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { person: result.data!, aliases: aliasesResult.data ?? [] };
|
const person = result.data!;
|
||||||
|
const personType = normalizePersonType(person.personType);
|
||||||
|
return { person: { ...person, personType }, aliases: aliasesResult.data ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
update: async ({ request, params, fetch }) => {
|
update: async ({ request, params, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
const personType = normalizePersonType(formData.get('personType')?.toString());
|
||||||
|
const title = formData.get('title')?.toString().trim() || undefined;
|
||||||
const firstName = formData.get('firstName')?.toString().trim();
|
const firstName = formData.get('firstName')?.toString().trim();
|
||||||
const lastName = formData.get('lastName')?.toString().trim();
|
const lastName = formData.get('lastName')?.toString().trim();
|
||||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||||
@@ -37,15 +46,18 @@ export const actions = {
|
|||||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||||
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
const deathYear = deathYearStr ? parseInt(deathYearStr, 10) : undefined;
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||||
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
|
if (validationKey) {
|
||||||
|
return fail(400, { updateError: resolveValidationMessage(validationKey) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const result = await api.PUT('/api/persons/{id}', {
|
const result = await api.PUT('/api/persons/{id}', {
|
||||||
params: { path: { id: params.id } },
|
params: { path: { id: params.id } },
|
||||||
body: {
|
body: {
|
||||||
firstName,
|
personType,
|
||||||
|
...(title ? { title } : {}),
|
||||||
|
...(firstName ? { firstName } : {}),
|
||||||
lastName,
|
lastName,
|
||||||
...(alias ? { alias } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(notes ? { notes } : {}),
|
...(notes ? { notes } : {}),
|
||||||
|
|||||||
@@ -1,93 +1,117 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
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 {
|
let { person }: { person: PersonFormData } = $props();
|
||||||
person
|
|
||||||
}: {
|
let selectedType = $state<PersonType>(
|
||||||
person: {
|
untrack(() =>
|
||||||
firstName?: string | null;
|
TYPES.includes(person.personType as PersonType) ? (person.personType as PersonType) : 'PERSON'
|
||||||
lastName: string;
|
)
|
||||||
alias?: string | null;
|
);
|
||||||
birthYear?: number | null;
|
|
||||||
deathYear?: number | null;
|
const isPerson = $derived(selectedType === 'PERSON');
|
||||||
notes?: string | null;
|
const lastNameLabel = $derived(
|
||||||
};
|
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
|
||||||
} = $props();
|
? 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>
|
</script>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div class="md:col-span-2">
|
||||||
<label for="firstName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
<p class={labelCls}>
|
||||||
>{m.form_label_first_name()} *</label
|
{m.form_label_person_type()}
|
||||||
>
|
</p>
|
||||||
<input
|
<PersonTypeSelector
|
||||||
id="firstName"
|
value={selectedType}
|
||||||
name="firstName"
|
name="personType"
|
||||||
type="text"
|
onchange={(type: PersonType) => (selectedType = type)}
|
||||||
required
|
|
||||||
value={person.firstName}
|
|
||||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="lastName" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
{#if isPerson}
|
||||||
>{m.form_label_last_name()} *</label
|
<div>
|
||||||
>
|
<label for="title" class={labelCls}>{m.form_label_title()}</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
maxlength="50"
|
||||||
|
value={person.title ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={person.firstName ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class={!isPerson ? 'md:col-span-2' : ''}>
|
||||||
|
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
|
||||||
<input
|
<input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
name="lastName"
|
name="lastName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={person.lastName}
|
value={person.lastName}
|
||||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class={inputCls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if isPerson}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
|
||||||
|
<input id="alias" name="alias" type="text" value={person.alias ?? ''} class={inputCls} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
|
||||||
|
<input
|
||||||
|
id="birthYear"
|
||||||
|
name="birthYear"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="2100"
|
||||||
|
placeholder={m.person_placeholder_year()}
|
||||||
|
value={person.birthYear ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
|
||||||
|
<input
|
||||||
|
id="deathYear"
|
||||||
|
name="deathYear"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="2100"
|
||||||
|
placeholder={m.person_placeholder_year()}
|
||||||
|
value={person.deathYear ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label for="alias" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
|
||||||
>{m.form_label_alias()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="alias"
|
|
||||||
name="alias"
|
|
||||||
type="text"
|
|
||||||
value={person.alias ?? ''}
|
|
||||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="birthYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.person_label_birth_year()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="birthYear"
|
|
||||||
name="birthYear"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="2100"
|
|
||||||
placeholder={m.person_placeholder_year()}
|
|
||||||
value={person.birthYear ?? ''}
|
|
||||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="deathYear" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.person_label_death_year()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="deathYear"
|
|
||||||
name="deathYear"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="2100"
|
|
||||||
placeholder={m.person_placeholder_year()}
|
|
||||||
value={person.deathYear ?? ''}
|
|
||||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label for="notes" class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase"
|
|
||||||
>{m.person_label_notes()}</label
|
|
||||||
>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="notes"
|
id="notes"
|
||||||
name="notes"
|
name="notes"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { normalizePersonType } from '$lib/person-validation';
|
||||||
|
|
||||||
|
describe('edit load — SKIP → UNKNOWN normalization', () => {
|
||||||
|
it('maps SKIP to UNKNOWN', () => {
|
||||||
|
expect(normalizePersonType('SKIP')).toBe('UNKNOWN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes PERSON through unchanged', () => {
|
||||||
|
expect(normalizePersonType('PERSON')).toBe('PERSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes INSTITUTION through unchanged', () => {
|
||||||
|
expect(normalizePersonType('INSTITUTION')).toBe('INSTITUTION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes GROUP through unchanged', () => {
|
||||||
|
expect(normalizePersonType('GROUP')).toBe('GROUP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes UNKNOWN through unchanged', () => {
|
||||||
|
expect(normalizePersonType('UNKNOWN')).toBe('UNKNOWN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults null to PERSON', () => {
|
||||||
|
expect(normalizePersonType(null)).toBe('PERSON');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
import {
|
||||||
|
normalizePersonType,
|
||||||
|
validatePersonFields,
|
||||||
|
resolveValidationMessage
|
||||||
|
} from '$lib/person-validation';
|
||||||
|
|
||||||
export async function load({ locals }: { locals: App.Locals }) {
|
export async function load({ locals }: { locals: App.Locals }) {
|
||||||
const canWrite =
|
const canWrite =
|
||||||
@@ -12,6 +18,8 @@ export async function load({ locals }: { locals: App.Locals }) {
|
|||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request, fetch }) => {
|
default: async ({ request, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
const personType = normalizePersonType(formData.get('personType')?.toString());
|
||||||
|
const title = formData.get('title')?.toString().trim() || undefined;
|
||||||
const firstName = formData.get('firstName')?.toString().trim();
|
const firstName = formData.get('firstName')?.toString().trim();
|
||||||
const lastName = formData.get('lastName')?.toString().trim();
|
const lastName = formData.get('lastName')?.toString().trim();
|
||||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||||
@@ -19,8 +27,16 @@ export const actions = {
|
|||||||
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
const deathYearStr = formData.get('deathYear')?.toString().trim();
|
||||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
const validationKey = validatePersonFields(personType, firstName, lastName);
|
||||||
return fail(400, { error: 'Vor- und Nachname sind Pflichtfelder.' });
|
if (validationKey) {
|
||||||
|
return fail(400, {
|
||||||
|
error: resolveValidationMessage(validationKey),
|
||||||
|
personType,
|
||||||
|
title,
|
||||||
|
firstName: firstName ?? '',
|
||||||
|
lastName: lastName ?? '',
|
||||||
|
alias
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
const birthYear = birthYearStr ? parseInt(birthYearStr, 10) : undefined;
|
||||||
@@ -29,8 +45,10 @@ export const actions = {
|
|||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const result = await api.POST('/api/persons', {
|
const result = await api.POST('/api/persons', {
|
||||||
body: {
|
body: {
|
||||||
firstName,
|
personType,
|
||||||
lastName,
|
...(title ? { title } : {}),
|
||||||
|
...(firstName ? { firstName } : {}),
|
||||||
|
lastName: lastName!,
|
||||||
...(alias ? { alias } : {}),
|
...(alias ? { alias } : {}),
|
||||||
...(birthYear ? { birthYear } : {}),
|
...(birthYear ? { birthYear } : {}),
|
||||||
...(deathYear ? { deathYear } : {}),
|
...(deathYear ? { deathYear } : {}),
|
||||||
@@ -39,7 +57,15 @@ export const actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, { error: 'Person konnte nicht gespeichert werden.' });
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
return fail(result.response.status, {
|
||||||
|
error: getErrorMessage(code),
|
||||||
|
personType,
|
||||||
|
title,
|
||||||
|
firstName,
|
||||||
|
lastName: lastName!,
|
||||||
|
alias
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, `/persons/${result.data!.id}`);
|
throw redirect(303, `/persons/${result.data!.id}`);
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import BackButton from '$lib/components/BackButton.svelte';
|
import BackButton from '$lib/components/BackButton.svelte';
|
||||||
|
import PersonTypeSelector from '$lib/components/PersonTypeSelector.svelte';
|
||||||
|
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person-validation';
|
||||||
|
|
||||||
let { form } = $props();
|
let { form } = $props();
|
||||||
|
|
||||||
|
let selectedType = $state<PersonType>(
|
||||||
|
untrack(() =>
|
||||||
|
TYPES.includes((form?.personType as PersonType) ?? 'PERSON')
|
||||||
|
? ((form?.personType as PersonType) ?? 'PERSON')
|
||||||
|
: 'PERSON'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isPerson = $derived(selectedType === 'PERSON');
|
||||||
|
const lastNameLabel = $derived(
|
||||||
|
selectedType === 'INSTITUTION' || selectedType === 'GROUP'
|
||||||
|
? m.form_label_name()
|
||||||
|
: m.form_label_last_name()
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
|
||||||
|
const labelCls = 'mb-1 block text-sm font-medium text-ink-2';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-2xl px-4 py-8">
|
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||||
<!-- Heading -->
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
<h1 class="font-serif text-3xl text-ink">{m.persons_new_heading()}</h1>
|
<h1 class="font-serif text-3xl text-ink">{m.persons_new_heading()}</h1>
|
||||||
@@ -22,79 +44,92 @@ let { form } = $props();
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||||
<div>
|
<div class="md:col-span-2">
|
||||||
<label for="firstName" class="mb-1 block text-sm font-medium text-ink-2"
|
<p class={labelCls}>{m.form_label_person_type()}</p>
|
||||||
>{m.form_label_first_name()} *</label
|
<PersonTypeSelector
|
||||||
>
|
value={selectedType}
|
||||||
<input
|
name="personType"
|
||||||
id="firstName"
|
onchange={(type: PersonType) => (selectedType = type)}
|
||||||
name="firstName"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{#if isPerson}
|
||||||
<label for="lastName" class="mb-1 block text-sm font-medium text-ink-2"
|
<div>
|
||||||
>{m.form_label_last_name()} *</label
|
<label for="title" class={labelCls}>{m.form_label_title()}</label>
|
||||||
>
|
<input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
maxlength="50"
|
||||||
|
value={form?.title ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="firstName" class={labelCls}>{m.form_label_first_name()} *</label>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form?.firstName ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class={!isPerson ? 'md:col-span-2' : ''}>
|
||||||
|
<label for="lastName" class={labelCls}>{lastNameLabel} *</label>
|
||||||
<input
|
<input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
name="lastName"
|
name="lastName"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
value={form?.lastName ?? ''}
|
||||||
|
class={inputCls}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if isPerson}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label for="alias" class={labelCls}>{m.form_label_alias()}</label>
|
||||||
|
<input
|
||||||
|
id="alias"
|
||||||
|
name="alias"
|
||||||
|
type="text"
|
||||||
|
placeholder={m.form_placeholder_alias()}
|
||||||
|
value={form?.alias ?? ''}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="birthYear" class={labelCls}>{m.person_label_birth_year()}</label>
|
||||||
|
<input
|
||||||
|
id="birthYear"
|
||||||
|
name="birthYear"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="2100"
|
||||||
|
placeholder={m.person_placeholder_year()}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="deathYear" class={labelCls}>{m.person_label_death_year()}</label>
|
||||||
|
<input
|
||||||
|
id="deathYear"
|
||||||
|
name="deathYear"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="2100"
|
||||||
|
placeholder={m.person_placeholder_year()}
|
||||||
|
class={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label for="alias" class="mb-1 block text-sm font-medium text-ink-2"
|
<label for="notes" class={labelCls}>{m.person_label_notes()}</label>
|
||||||
>{m.form_label_alias()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="alias"
|
|
||||||
name="alias"
|
|
||||||
type="text"
|
|
||||||
placeholder={m.form_placeholder_alias()}
|
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="birthYear" class="mb-1 block text-sm font-medium text-ink-2"
|
|
||||||
>{m.person_label_birth_year()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="birthYear"
|
|
||||||
name="birthYear"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="2100"
|
|
||||||
placeholder={m.person_placeholder_year()}
|
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="deathYear" class="mb-1 block text-sm font-medium text-ink-2"
|
|
||||||
>{m.person_label_death_year()}</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="deathYear"
|
|
||||||
name="deathYear"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="2100"
|
|
||||||
placeholder={m.person_placeholder_year()}
|
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<label for="notes" class="mb-1 block text-sm font-medium text-ink-2"
|
|
||||||
>{m.person_label_notes()}</label
|
|
||||||
>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="notes"
|
id="notes"
|
||||||
name="notes"
|
name="notes"
|
||||||
@@ -106,7 +141,6 @@ let { form } = $props();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save Bar -->
|
|
||||||
<div
|
<div
|
||||||
class="mt-4 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
|
class="mt-4 flex items-center justify-between rounded-sm border border-line bg-surface px-6 py-4 shadow-sm"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user