Compare commits
163 Commits
0b33f323ee
...
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 | ||
|
|
c59287fcfc | ||
|
|
8ce96294b0 | ||
|
|
1803db86b5 | ||
|
|
46001bbf9d | ||
|
|
af8303dbf8 | ||
|
|
7df00859c6 | ||
|
|
92d623e298 | ||
|
|
156efe8b31 | ||
|
|
499beca124 | ||
|
|
5cbb14d4a3 | ||
|
|
2bb8fb8968 | ||
|
|
f13f635161 | ||
|
|
6d3489d035 | ||
|
|
fa5dc43864 | ||
|
|
d4f32ed5d4 | ||
|
|
27e3d290e7 | ||
|
|
25446c9a5c | ||
|
|
660e34e016 | ||
|
|
b662117e55 | ||
|
|
d251806e72 | ||
|
|
f0da033ec9 | ||
|
|
a59feec81a | ||
|
|
779ffaab55 | ||
|
|
b690c74ddf | ||
|
|
0797406f02 | ||
|
|
c94d2cec03 | ||
|
|
4da0bf71a0 | ||
|
|
da5d3c60b3 | ||
|
|
ed0d0bf331 | ||
|
|
899508f9ca | ||
|
|
d32e671e9d | ||
|
|
b61cfa081f | ||
|
|
d914385afc | ||
|
|
6cdfc1f6a3 | ||
|
|
ed6a2fb56f | ||
|
|
58545876cd | ||
|
|
687ebf495d | ||
|
|
bc10f2af06 | ||
|
|
0bfd342190 | ||
|
|
1973f88e56 | ||
|
|
9f044f429c | ||
|
|
7ad5e35fd6 | ||
|
|
e7afed5ac3 | ||
|
|
f48d1e3cd8 | ||
|
|
fc118f7032 | ||
|
|
4229e952fb | ||
|
|
e1259215ef | ||
|
|
f06d034b36 | ||
|
|
a6cd10f219 | ||
|
|
b8e6fe9ec9 | ||
|
|
763f1990cd | ||
|
|
ca62f50921 | ||
|
|
61f84a86ac | ||
|
|
0eb5c95c6c | ||
|
|
d662635392 | ||
|
|
b00be2548c | ||
|
|
01a8654347 | ||
|
|
c1b221412f | ||
|
|
76c14ea604 | ||
|
|
539842e849 | ||
|
|
ef7a51fe30 | ||
|
|
ec17cb123a | ||
|
|
801470093d | ||
|
|
af6ba6a9cc | ||
|
|
9acd5ec617 | ||
|
|
29a44b3cd1 | ||
|
|
5fe289b06b | ||
|
|
f76af8c678 | ||
|
|
69c739c6e3 | ||
|
|
43cf022f05 | ||
|
|
48d034dcb8 | ||
|
|
c335ddd686 | ||
|
|
7830a749a0 | ||
|
|
5b7c37391c | ||
|
|
ce72b07197 | ||
|
|
505804c893 | ||
|
|
67421a4c0c | ||
|
|
0ea0df4f72 | ||
|
|
077f5c85df | ||
|
|
018e272a3b | ||
|
|
0c4a0ead7b | ||
|
|
82b12d4383 | ||
|
|
01758e8e00 | ||
|
|
c3fac5b0ad | ||
|
|
03b180fe88 | ||
|
|
b234db0472 | ||
|
|
7c3a8e7651 | ||
|
|
7fb9d74515 | ||
|
|
dff203d526 | ||
|
|
86584a53a8 | ||
|
|
1d5219eac4 | ||
|
|
6e021fb23a | ||
|
|
bdac5e42ad | ||
|
|
18b88672ec | ||
|
|
8fa061187e | ||
|
|
610915b2a2 | ||
|
|
78ac5d663d | ||
|
|
826c0827dc | ||
|
|
7a75ffed76 | ||
|
|
1299bd5938 | ||
|
|
8f28a99e00 | ||
|
|
7007491d8c | ||
|
|
629f0183f7 | ||
|
|
72cd6f5bbc | ||
|
|
1d44bbb1bd | ||
|
|
a02f6cdcd7 | ||
|
|
817749889a | ||
|
|
a8b9133b80 | ||
|
|
510ab1d2d5 | ||
|
|
ad999c47ea | ||
|
|
9862a51ac7 | ||
|
|
df260d5c64 | ||
|
|
096f66eb15 |
@@ -26,7 +26,16 @@ public enum AuditKind {
|
||||
COMMENT_ADDED,
|
||||
|
||||
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
||||
MENTION_CREATED;
|
||||
MENTION_CREATED,
|
||||
|
||||
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
||||
USER_CREATED,
|
||||
|
||||
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
||||
USER_DELETED,
|
||||
|
||||
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
|
||||
GROUP_MEMBERSHIP_CHANGED;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -197,4 +199,6 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
||||
ORDER BY ranked.document_id, ranked.rn
|
||||
""", nativeQuery = true)
|
||||
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
||||
|
||||
Page<AuditLog> findByKindIn(Collection<AuditKind> kinds, Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.*;
|
||||
|
||||
import static org.raddatz.familienarchiv.audit.AuditKind.GROUP_MEMBERSHIP_CHANGED;
|
||||
import static org.raddatz.familienarchiv.audit.AuditKind.USER_CREATED;
|
||||
import static org.raddatz.familienarchiv.audit.AuditKind.USER_DELETED;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditLogQueryService {
|
||||
@@ -51,6 +57,11 @@ public class AuditLogQueryService {
|
||||
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
||||
}
|
||||
|
||||
public List<AuditLog> findRecentUserManagementEvents(int limit) {
|
||||
PageRequest page = PageRequest.of(0, limit, Sort.by("happenedAt").descending());
|
||||
return queryRepository.findByKindIn(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), page).getContent();
|
||||
}
|
||||
|
||||
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
||||
for (ContributorRow row : rows) {
|
||||
|
||||
@@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
||||
boolean existsByKind(AuditKind kind);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -13,6 +14,18 @@ import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.raddatz.familienarchiv.dto.BatchMetadataRequest;
|
||||
import org.raddatz.familienarchiv.dto.BulkEditError;
|
||||
import org.raddatz.familienarchiv.dto.BulkEditResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||
@@ -62,6 +75,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@RequestMapping("/api/documents")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Validated
|
||||
public class DocumentController {
|
||||
|
||||
private final DocumentService documentService;
|
||||
@@ -187,6 +201,7 @@ public class DocumentController {
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public QuickUploadResult quickUpload(
|
||||
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
||||
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
|
||||
Authentication authentication) {
|
||||
List<Document> created = new ArrayList<>();
|
||||
List<Document> updated = new ArrayList<>();
|
||||
@@ -196,14 +211,21 @@ public class DocumentController {
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
documentService.validateBatch(files.size(), metadata);
|
||||
|
||||
UUID actorId = requireUserId(authentication);
|
||||
for (MultipartFile file : files) {
|
||||
long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();
|
||||
|
||||
for (int i = 0; i < files.size(); i++) {
|
||||
MultipartFile file = files.get(i);
|
||||
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
DocumentService.StoreResult result = documentService.storeDocument(file, actorId);
|
||||
DocumentService.StoreResult result = metadata != null
|
||||
? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
|
||||
: documentService.storeDocument(file, actorId);
|
||||
if (result.isNew()) {
|
||||
created.add(result.document());
|
||||
} else {
|
||||
@@ -215,9 +237,107 @@ public class DocumentController {
|
||||
}
|
||||
}
|
||||
|
||||
log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}",
|
||||
actorId, files.size(), totalBytes, metadata != null,
|
||||
created.size(), updated.size(), errors.size());
|
||||
|
||||
return new QuickUploadResult(created, updated, errors);
|
||||
}
|
||||
|
||||
// --- BULK EDIT ---
|
||||
|
||||
private static final int BULK_EDIT_MAX_IDS = 500;
|
||||
/** Hard cap for {@code GET /api/documents/ids}: prevents an unfiltered
|
||||
* call from materialising the entire {@code documents} table into JSON.
|
||||
* Generous enough for real-world "Alle X editieren" against the family
|
||||
* archive's bounded scale (~1500 docs today, expected growth to ~5k). */
|
||||
private static final int BULK_EDIT_FILTER_MAX_IDS = 5000;
|
||||
|
||||
@PatchMapping("/bulk")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public BulkEditResult patchBulk(
|
||||
@RequestBody @Valid DocumentBulkEditDTO dto,
|
||||
Authentication authentication) {
|
||||
if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
|
||||
}
|
||||
if (dto.getDocumentIds().size() > BULK_EDIT_MAX_IDS) {
|
||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
||||
"Maximum " + BULK_EDIT_MAX_IDS + " documents per request, got: " + dto.getDocumentIds().size());
|
||||
}
|
||||
|
||||
UUID actorId = requireUserId(authentication);
|
||||
int updated = 0;
|
||||
List<BulkEditError> errors = new ArrayList<>();
|
||||
|
||||
// Dedupe duplicate document IDs while preserving submission order. A
|
||||
// double-click on "Alle X editieren" would otherwise hit each document
|
||||
// twice and inflate the `updated` count returned to the user.
|
||||
LinkedHashSet<UUID> uniqueIds = new LinkedHashSet<>(dto.getDocumentIds());
|
||||
|
||||
for (UUID id : uniqueIds) {
|
||||
try {
|
||||
documentService.applyBulkEditToDocument(id, dto, actorId);
|
||||
updated++;
|
||||
} catch (DomainException e) {
|
||||
errors.add(new BulkEditError(id, sanitizeForLog(e.getMessage())));
|
||||
} catch (Exception e) {
|
||||
errors.add(new BulkEditError(id, "Internal error"));
|
||||
log.warn("Bulk edit failed for document {}: {}", id, sanitizeForLog(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("bulkEdit actor={} documentIds={} unique={} updated={} errors={}",
|
||||
actorId, dto.getDocumentIds().size(), uniqueIds.size(), updated, errors.size());
|
||||
|
||||
return new BulkEditResult(updated, errors);
|
||||
}
|
||||
|
||||
/** CRLF strip for any log line interpolating a free-form string (e.g.
|
||||
* {@link Throwable#getMessage()}). Defends against CWE-117 log injection. */
|
||||
private static String sanitizeForLog(String s) {
|
||||
return s == null ? null : s.replaceAll("[\\r\\n]", "_");
|
||||
}
|
||||
|
||||
@GetMapping("/ids")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public List<UUID> getDocumentIds(
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) LocalDate from,
|
||||
@RequestParam(required = false) LocalDate to,
|
||||
@RequestParam(required = false) UUID senderId,
|
||||
@RequestParam(required = false) UUID receiverId,
|
||||
@RequestParam(required = false, name = "tag") List<String> tags,
|
||||
@RequestParam(required = false) String tagQ,
|
||||
@RequestParam(required = false) DocumentStatus status,
|
||||
@RequestParam(required = false) String tagOp,
|
||||
Authentication authentication) {
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator);
|
||||
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
|
||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
||||
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
|
||||
}
|
||||
UUID actorId = requireUserId(authentication);
|
||||
log.info("documentIds actor={} matched={}", actorId, ids.size());
|
||||
return ids;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public List<DocumentBatchSummary> batchMetadata(@RequestBody @Valid BatchMetadataRequest request, Authentication authentication) {
|
||||
if (request == null || request.ids() == null || request.ids().isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required");
|
||||
}
|
||||
if (request.ids().size() > BULK_EDIT_MAX_IDS) {
|
||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
||||
"Maximum " + BULK_EDIT_MAX_IDS + " ids per request, got: " + request.ids().size());
|
||||
}
|
||||
UUID actorId = requireUserId(authentication);
|
||||
log.info("batchMetadata actor={} ids={}", actorId, request.ids().size());
|
||||
return documentService.batchMetadata(request.ids());
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete-count")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Map<String, Long> getIncompleteCount() {
|
||||
@@ -252,14 +372,20 @@ public class DocumentController {
|
||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
||||
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
|
||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
|
||||
// @Max on page guards against overflow when pageable.getOffset() is computed
|
||||
// as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
|
||||
// Hibernate cheerfully turns into an invalid SQL OFFSET.
|
||||
@Parameter(description = "Page number (0-indexed)") @RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page,
|
||||
@Parameter(description = "Page size (max 100)") @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
|
||||
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
||||
}
|
||||
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
||||
// defaults to AND, which matches the frontend default and keeps old clients working.
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator));
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
|
||||
}
|
||||
|
||||
// --- TRAINING LABELS ---
|
||||
|
||||
@@ -63,27 +63,33 @@ public class PersonController {
|
||||
@PostMapping
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||
}
|
||||
dto.setFirstName(dto.getFirstName().trim());
|
||||
validatePersonNames(dto);
|
||||
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||
dto.setLastName(dto.getLastName().trim());
|
||||
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||
return ResponseEntity.ok(personService.createPerson(dto));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||
}
|
||||
dto.setFirstName(dto.getFirstName().trim());
|
||||
validatePersonNames(dto);
|
||||
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||
dto.setLastName(dto.getLastName().trim());
|
||||
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
||||
}
|
||||
|
||||
private void validatePersonNames(PersonUpdateDTO dto) {
|
||||
if (dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nachname ist Pflichtfeld");
|
||||
}
|
||||
if (dto.getPersonType() == org.raddatz.familienarchiv.model.PersonType.PERSON
|
||||
&& (dto.getFirstName() == null || dto.getFirstName().isBlank())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vorname ist Pflichtfeld");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/merge")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
|
||||
@@ -78,24 +78,31 @@ public class UserController {
|
||||
|
||||
@PostMapping("/users")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
|
||||
return ResponseEntity.ok(userService.createUserOrUpdate(request));
|
||||
public ResponseEntity<AppUser> createUser(Authentication authentication,
|
||||
@Valid @RequestBody CreateUserRequest request) {
|
||||
return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request));
|
||||
}
|
||||
|
||||
@PutMapping("/users/{id}")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
|
||||
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
|
||||
@PathVariable UUID id,
|
||||
@RequestBody AdminUpdateUserRequest dto) {
|
||||
AppUser updated = userService.adminUpdateUser(id, dto);
|
||||
AppUser updated = userService.adminUpdateUser(actorId(authentication), id, dto);
|
||||
updated.setPassword(null);
|
||||
return ResponseEntity.ok(updated);
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{id}")
|
||||
@RequirePermission(Permission.ADMIN_USER)
|
||||
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
|
||||
userService.deleteUser(id);
|
||||
public ResponseEntity<Void> deleteUser(Authentication authentication,
|
||||
@PathVariable UUID id) {
|
||||
userService.deleteUser(actorId(authentication), id);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
private UUID actorId(Authentication auth) {
|
||||
return userService.findByEmail(auth.getName()).getId();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ public class DashboardService {
|
||||
.toList();
|
||||
|
||||
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
|
||||
totalBlocks, pct, null, collaborators);
|
||||
totalBlocks, pct, doc.getThumbnailUrl(), collaborators);
|
||||
}
|
||||
|
||||
public DashboardPulseDTO getPulse(UUID userId) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BatchMetadataRequest(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<UUID> ids) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BulkEditError(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String message) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record BulkEditResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int updated,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<BulkEditError> errors) {}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
public class DocumentBatchMetadataDTO {
|
||||
private List<String> titles;
|
||||
private UUID senderId;
|
||||
private List<UUID> receiverIds;
|
||||
private LocalDate documentDate;
|
||||
private String location;
|
||||
private List<String> tagNames;
|
||||
private Boolean metadataComplete;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
public record DocumentBatchSummary(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String pdfUrl) {}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Request body for {@code PATCH /api/documents/bulk}. Field semantics:
|
||||
* <ul>
|
||||
* <li>{@code tagNames} and {@code receiverIds} are <b>additive</b> —
|
||||
* merged into each document's existing set, never replacing it.</li>
|
||||
* <li>{@code senderId}, {@code documentLocation}, {@code archiveBox},
|
||||
* {@code archiveFolder} are <b>replace-on-non-blank</b> — null/blank
|
||||
* fields are skipped, anything else overwrites.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Kept as a Lombok {@code @Data} POJO (not a record) for symmetry with
|
||||
* the existing {@code DocumentUpdateDTO} and to keep test setup terse —
|
||||
* the per-feature DTOs introduced alongside this one ({@link BulkEditError},
|
||||
* {@link BulkEditResult}, {@link BatchMetadataRequest},
|
||||
* {@link DocumentBatchSummary}) <i>are</i> records because they have no
|
||||
* test-side mutation. Tracked in the cycle-1 review for follow-up.
|
||||
*
|
||||
* <p>Bean-validation caps below defend against payload-amplification: the
|
||||
* 1 MiB SvelteKit proxy cap allows ~26k UUIDs through to the backend, and
|
||||
* Jetty's default body limit is 8 MB. {@code @Size} guards catch malformed
|
||||
* clients without depending on those outer bounds.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DocumentBulkEditDTO {
|
||||
|
||||
// No @Size cap here on purpose: the controller's BULK_EDIT_MAX_IDS check
|
||||
// returns the typed BULK_EDIT_TOO_MANY_IDS error code, which the frontend
|
||||
// maps to a localised "Maximal 500 …" message via Paraglide. A bean-
|
||||
// validation @Size would short-circuit that with a generic VALIDATION_ERROR.
|
||||
private List<UUID> documentIds;
|
||||
|
||||
@Size(max = 200, message = "tagNames must not exceed 200 entries")
|
||||
private List<@Size(max = 200, message = "tagName must not exceed 200 chars") String> tagNames;
|
||||
|
||||
private UUID senderId;
|
||||
|
||||
@Size(max = 200, message = "receiverIds must not exceed 200 entries")
|
||||
private List<UUID> receiverIds;
|
||||
|
||||
@Size(max = 255, message = "documentLocation must not exceed 255 chars")
|
||||
private String documentLocation;
|
||||
|
||||
@Size(max = 255, message = "archiveBox must not exceed 255 chars")
|
||||
private String archiveBox;
|
||||
|
||||
@Size(max = 255, message = "archiveFolder must not exceed 255 chars")
|
||||
private String archiveFolder;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -8,9 +9,30 @@ public record DocumentSearchResult(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<DocumentSearchItem> items,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
long total
|
||||
long totalElements,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int pageNumber,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int pageSize,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
int totalPages
|
||||
) {
|
||||
/**
|
||||
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
||||
* don't care about paging. Treats the whole list as page 0 of itself.
|
||||
*/
|
||||
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
|
||||
return new DocumentSearchResult(items, items.size());
|
||||
int size = items.size();
|
||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged factory used by the service when it has a real Pageable + full match count
|
||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
||||
*/
|
||||
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
|
||||
int pageSize = pageable.getPageSize();
|
||||
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ public class DocumentUpdateDTO {
|
||||
private LocalDate documentDate;
|
||||
private String location;
|
||||
private String documentLocation;
|
||||
private String archiveBox;
|
||||
private String archiveFolder;
|
||||
private String transcription;
|
||||
private String summary;
|
||||
private UUID senderId;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.raddatz.familienarchiv.model.PersonType;
|
||||
|
||||
@Data
|
||||
public class PersonUpdateDTO {
|
||||
@NotNull
|
||||
private PersonType personType;
|
||||
@Size(max = 50)
|
||||
private String title;
|
||||
@Size(max = 100)
|
||||
|
||||
@@ -13,6 +13,8 @@ public enum ErrorCode {
|
||||
PERSON_NOT_FOUND,
|
||||
/** A person name alias with the given ID does not exist. 404 */
|
||||
ALIAS_NOT_FOUND,
|
||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||
INVALID_PERSON_TYPE,
|
||||
|
||||
// --- Documents ---
|
||||
/** A document with the given ID does not exist. 404 */
|
||||
@@ -109,6 +111,10 @@ public enum ErrorCode {
|
||||
// --- Generic ---
|
||||
/** Request validation failed (missing or malformed fields). 400 */
|
||||
VALIDATION_ERROR,
|
||||
/** Batch upload exceeds the maximum allowed file count per request. 400 */
|
||||
BATCH_TOO_LARGE,
|
||||
/** Bulk edit request exceeds the per-request document ID cap. 400 */
|
||||
BULK_EDIT_TOO_MANY_IDS,
|
||||
/** An unexpected server-side error occurred. 500 */
|
||||
INTERNAL_ERROR,
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
@@ -131,4 +134,19 @@ public class Document {
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Builder.Default
|
||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
||||
|
||||
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
|
||||
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
||||
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
||||
// this URL changes whenever the underlying file does. Dropping the query param
|
||||
// would let browsers serve a stale thumbnail for a year after the file is
|
||||
// replaced, and shared caches could leak one user's thumbnail to another
|
||||
// (CWE-525).
|
||||
@JsonProperty("thumbnailUrl")
|
||||
public String getThumbnailUrl() {
|
||||
if (thumbnailKey == null) return null;
|
||||
String base = "/api/documents/" + id + "/thumbnail";
|
||||
if (thumbnailGeneratedAt == null) return base;
|
||||
return base + "?v=" + URLEncoder.encode(thumbnailGeneratedAt.toString(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
SELECT d.id FROM documents d
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||
THEN to_tsquery('german', regexp_replace(
|
||||
THEN to_tsquery('simple', regexp_replace(
|
||||
websearch_to_tsquery('german', :query)::text,
|
||||
'''([^'']+)''',
|
||||
'''\\1'':*',
|
||||
@@ -149,7 +149,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
FROM documents d
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||
THEN to_tsquery('german', regexp_replace(
|
||||
THEN to_tsquery('simple', regexp_replace(
|
||||
websearch_to_tsquery('german', :query)::text,
|
||||
'''([^'']+)''',
|
||||
'''\\1'':*',
|
||||
|
||||
@@ -7,6 +7,9 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
@@ -22,7 +25,9 @@ import org.raddatz.familienarchiv.model.TrainingLabel;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
@@ -130,6 +135,52 @@ public class DocumentService {
|
||||
return new StoreResult(saved, isNew);
|
||||
}
|
||||
|
||||
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
|
||||
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
|
||||
if (fileCount > 50) {
|
||||
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
|
||||
}
|
||||
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public StoreResult storeDocumentWithBatchMetadata(
|
||||
MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {
|
||||
StoreResult base = storeDocument(file, actorId);
|
||||
Document doc = applyBatchMetadata(base.document(), metadata, fileIndex);
|
||||
return new StoreResult(documentRepository.save(doc), base.isNew());
|
||||
}
|
||||
|
||||
private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) {
|
||||
if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) {
|
||||
doc.setTitle(metadata.getTitles().get(fileIndex));
|
||||
}
|
||||
if (metadata.getSenderId() != null) {
|
||||
doc.setSender(personService.getById(metadata.getSenderId()));
|
||||
}
|
||||
if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) {
|
||||
doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds())));
|
||||
}
|
||||
if (metadata.getDocumentDate() != null) {
|
||||
doc.setDocumentDate(metadata.getDocumentDate());
|
||||
}
|
||||
if (metadata.getLocation() != null) {
|
||||
doc.setLocation(metadata.getLocation());
|
||||
}
|
||||
if (metadata.getMetadataComplete() != null) {
|
||||
doc.setMetadataComplete(metadata.getMetadataComplete());
|
||||
}
|
||||
if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) {
|
||||
UUID docId = doc.getId();
|
||||
updateDocumentTags(docId, metadata.getTagNames());
|
||||
doc = documentRepository.findById(docId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId));
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
||||
String filename = (file != null && !file.isEmpty())
|
||||
@@ -220,6 +271,8 @@ public class DocumentService {
|
||||
doc.setTranscription(dto.getTranscription());
|
||||
doc.setSummary(dto.getSummary());
|
||||
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||
doc.setArchiveBox(dto.getArchiveBox());
|
||||
doc.setArchiveFolder(dto.getArchiveFolder());
|
||||
|
||||
List<String> tags = new ArrayList<>();
|
||||
if (dto.getTags() != null && !dto.getTags().isBlank()) {
|
||||
@@ -285,20 +338,143 @@ public class DocumentService {
|
||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||
Document doc = documentRepository.findById(docId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
||||
doc.setTags(resolveTags(tagNames));
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
|
||||
Set<Tag> newTags = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Resolves a list of tag-name strings to {@link Tag} entities, trimming
|
||||
* whitespace and skipping blank entries. Single source of truth for
|
||||
* "name string → Tag" so the find-or-create policy stays consistent
|
||||
* across single-doc updates ({@link #updateDocumentTags}), bulk edits
|
||||
* ({@link #applyBulkEditToDocument}), and the upload-batch path
|
||||
* ({@code applyBatchMetadata}).
|
||||
*/
|
||||
private Set<Tag> resolveTags(List<String> tagNames) {
|
||||
if (tagNames == null || tagNames.isEmpty()) return new HashSet<>();
|
||||
Set<Tag> resolved = new HashSet<>();
|
||||
for (String name : tagNames) {
|
||||
// Clean the string
|
||||
String cleanName = name.trim();
|
||||
if (cleanName.isEmpty())
|
||||
continue;
|
||||
if (cleanName.isEmpty()) continue;
|
||||
resolved.add(tagService.findOrCreate(cleanName));
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
newTags.add(tagService.findOrCreate(cleanName));
|
||||
/**
|
||||
* Returns all document IDs matching the given filter parameters, ignoring
|
||||
* pagination. Used by the bulk-edit "Alle X editieren" fast path so the
|
||||
* frontend can replace the selection with every match across pages in one
|
||||
* round-trip.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
List<UUID> rankedIds = null;
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
||||
if (rankedIds.isEmpty()) return List.of();
|
||||
}
|
||||
|
||||
doc.setTags(newTags);
|
||||
return documentRepository.save(doc);
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the search Specification chain. Shared by
|
||||
* {@link #searchDocuments} (paged + sorted) and {@link #findIdsForFilter}
|
||||
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
|
||||
* full-text query returned no rows.
|
||||
*/
|
||||
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
|
||||
LocalDate from, LocalDate to,
|
||||
UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ,
|
||||
DocumentStatus status, TagOperator tagOperator) {
|
||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
||||
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
|
||||
return Specification.where(textSpec)
|
||||
.and(isBetween(from, to))
|
||||
.and(hasSender(sender))
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(expandedTagSets, useOrLogic))
|
||||
.and(hasTagPartial(tagQ))
|
||||
.and(hasStatus(status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns lightweight summaries (id, title, server PDF URL) for the given
|
||||
* document IDs. Unknown IDs are silently dropped — the consumer is the
|
||||
* bulk-edit page's left strip, where missing previews would already be
|
||||
* obvious; surfacing them as errors here adds no value.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<DocumentBatchSummary> batchMetadata(List<UUID> ids) {
|
||||
if (ids == null || ids.isEmpty()) return List.of();
|
||||
return documentRepository.findAllById(ids).stream()
|
||||
.map(d -> new DocumentBatchSummary(
|
||||
d.getId(),
|
||||
d.getTitle() != null ? d.getTitle() : d.getOriginalFilename(),
|
||||
"/api/documents/" + d.getId() + "/file"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a bulk-edit DTO to a single document atomically.
|
||||
* Tags and receivers are additive (merged into existing sets); sender and the
|
||||
* three location fields are replace-on-non-blank (null/blank means "no change").
|
||||
* Wrapped in its own transaction so a failure on one document never partially
|
||||
* mutates another in the controller's batch loop.
|
||||
*
|
||||
* Each successful update emits a {@link AuditKind#METADATA_UPDATED} audit
|
||||
* event tagged {@code source=BULK_EDIT} and writes a row to
|
||||
* {@code document_versions} so the family archive's "who changed what"
|
||||
* trail stays complete across both single- and bulk-doc edit paths.
|
||||
*
|
||||
* NOTE on N+1: tag and person resolution happens per-document. With 500
|
||||
* documents × 10 tags this fans out to ~5000 tag-resolve queries per
|
||||
* request. Acceptable today because the family archive is bounded at
|
||||
* ~1500 documents total. Tracked as a perf follow-up.
|
||||
*/
|
||||
@Transactional
|
||||
public Document applyBulkEditToDocument(UUID id, DocumentBulkEditDTO dto, UUID actorId) {
|
||||
Document doc = documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
|
||||
if (dto.getTagNames() != null && !dto.getTagNames().isEmpty()) {
|
||||
Set<Tag> merged = new HashSet<>(doc.getTags());
|
||||
merged.addAll(resolveTags(dto.getTagNames()));
|
||||
doc.setTags(merged);
|
||||
}
|
||||
|
||||
if (dto.getSenderId() != null) {
|
||||
doc.setSender(personService.getById(dto.getSenderId()));
|
||||
}
|
||||
|
||||
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
|
||||
Set<Person> merged = new HashSet<>(doc.getReceivers());
|
||||
merged.addAll(personService.getAllById(dto.getReceiverIds()));
|
||||
doc.setReceivers(merged);
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(dto.getDocumentLocation())) {
|
||||
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||
}
|
||||
if (StringUtils.hasText(dto.getArchiveBox())) {
|
||||
doc.setArchiveBox(dto.getArchiveBox());
|
||||
}
|
||||
if (StringUtils.hasText(dto.getArchiveFolder())) {
|
||||
doc.setArchiveFolder(dto.getArchiveFolder());
|
||||
}
|
||||
|
||||
Document saved = documentRepository.save(doc);
|
||||
documentVersionService.recordVersion(saved);
|
||||
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(),
|
||||
Map.of("source", "BULK_EDIT"));
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -355,7 +531,7 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator) {
|
||||
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
List<UUID> rankedIds = null;
|
||||
|
||||
@@ -364,27 +540,21 @@ public class DocumentService {
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
}
|
||||
|
||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||
|
||||
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
|
||||
Specification<Document> spec = Specification.where(textSpec)
|
||||
.and(isBetween(from, to))
|
||||
.and(hasSender(sender))
|
||||
.and(hasReceiver(receiver))
|
||||
.and(hasTags(expandedTagSets, useOrLogic))
|
||||
.and(hasTagPartial(tagQ))
|
||||
.and(hasStatus(status));
|
||||
|
||||
// SENDER and RECEIVER are sorted in-memory because JPA's Sort.by("sender.lastName")
|
||||
// generates an INNER JOIN that silently drops documents with null sender/receivers.
|
||||
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
||||
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
|
||||
// rank list. Cost scales linearly with match count — acceptable while documents
|
||||
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
|
||||
if (sort == DocumentSort.RECEIVER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
return buildResult(sortByFirstReceiver(results, dir), text);
|
||||
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
|
||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||
}
|
||||
if (sort == DocumentSort.SENDER) {
|
||||
List<Document> results = documentRepository.findAll(spec);
|
||||
return buildResult(sortBySender(results, dir), text);
|
||||
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
|
||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||
}
|
||||
|
||||
// RELEVANCE: default when text present and no explicit sort given
|
||||
@@ -397,15 +567,26 @@ public class DocumentService {
|
||||
.sorted(Comparator.comparingInt(
|
||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||
.toList();
|
||||
return buildResult(sorted, text);
|
||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||
}
|
||||
|
||||
Sort springSort = resolveSort(sort, dir);
|
||||
List<Document> results = documentRepository.findAll(spec, springSort);
|
||||
return buildResult(results, text);
|
||||
// Fast path — push sort + paging into the DB and enrich only the returned slice.
|
||||
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort, dir));
|
||||
Page<Document> page = documentRepository.findAll(spec, pageRequest);
|
||||
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
||||
}
|
||||
|
||||
private DocumentSearchResult buildResult(List<Document> documents, String text) {
|
||||
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
|
||||
int from = Math.min((int) pageable.getOffset(), sorted.size());
|
||||
int to = Math.min(from + pageable.getPageSize(), sorted.size());
|
||||
return sorted.subList(from, to);
|
||||
}
|
||||
|
||||
private DocumentSearchResult buildResultPaged(List<Document> slice, String text, Pageable pageable, long totalElements) {
|
||||
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
|
||||
}
|
||||
|
||||
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
|
||||
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
||||
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
||||
|
||||
@@ -413,14 +594,12 @@ public class DocumentService {
|
||||
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
||||
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
||||
|
||||
List<DocumentSearchItem> items = colorResolved.stream().map(doc -> new DocumentSearchItem(
|
||||
return colorResolved.stream().map(doc -> new DocumentSearchItem(
|
||||
doc,
|
||||
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
||||
completionByDoc.getOrDefault(doc.getId(), 0),
|
||||
contributorsByDoc.getOrDefault(doc.getId(), List.of())
|
||||
)).toList();
|
||||
|
||||
return DocumentSearchResult.of(items);
|
||||
}
|
||||
|
||||
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
||||
|
||||
@@ -109,8 +109,12 @@ public class PersonService {
|
||||
|
||||
@Transactional
|
||||
public Person createPerson(PersonUpdateDTO dto) {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
Person person = Person.builder()
|
||||
.personType(dto.getPersonType())
|
||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||
.firstName(dto.getFirstName())
|
||||
.lastName(dto.getLastName())
|
||||
@@ -136,9 +140,13 @@ public class PersonService {
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
Person person = personRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||
person.setPersonType(dto.getPersonType());
|
||||
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
||||
person.setFirstName(dto.getFirstName());
|
||||
person.setLastName(dto.getLastName());
|
||||
|
||||
@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
@@ -21,10 +23,13 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static java.util.stream.Collectors.toSet;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@@ -33,9 +38,10 @@ public class UserService {
|
||||
private final AppUserRepository userRepository;
|
||||
private final UserGroupRepository groupRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AuditService auditService;
|
||||
|
||||
@Transactional
|
||||
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
||||
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
|
||||
log.info("Creating or updating user: {}", request.getEmail());
|
||||
|
||||
Set<UserGroup> groups = new HashSet<>();
|
||||
@@ -45,10 +51,12 @@ public class UserService {
|
||||
|
||||
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
||||
AppUser user;
|
||||
boolean isNew;
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
log.info("User exists, updating: {}", request.getEmail());
|
||||
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||
isNew = false;
|
||||
} else {
|
||||
log.info("Creating new user: {}", request.getEmail());
|
||||
user = AppUser.builder()
|
||||
@@ -61,8 +69,42 @@ public class UserService {
|
||||
.contact(request.getContact())
|
||||
.enabled(true)
|
||||
.build();
|
||||
isNew = true;
|
||||
}
|
||||
|
||||
AppUser saved = userRepository.save(user);
|
||||
if (isNew) {
|
||||
auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null,
|
||||
Map.of("userId", saved.getId().toString(), "email", saved.getEmail()));
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser createUserForBootstrap(CreateUserRequest request) {
|
||||
log.info("Bootstrap user creation (no audit): {}", request.getEmail());
|
||||
|
||||
Set<UserGroup> groups = new HashSet<>();
|
||||
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
||||
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
|
||||
}
|
||||
|
||||
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
||||
if (existingUser.isPresent()) {
|
||||
AppUser updated = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||
return userRepository.save(updated);
|
||||
}
|
||||
|
||||
AppUser user = AppUser.builder()
|
||||
.email(request.getEmail())
|
||||
.password(passwordEncoder.encode(request.getInitialPassword()))
|
||||
.groups(groups)
|
||||
.firstName(request.getFirstName())
|
||||
.lastName(request.getLastName())
|
||||
.birthDate(request.getBirthDate())
|
||||
.contact(request.getContact())
|
||||
.enabled(true)
|
||||
.build();
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
@@ -94,10 +136,13 @@ public class UserService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteUser(UUID userId) {
|
||||
public void deleteUser(UUID actorId, UUID userId) {
|
||||
AppUser user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
||||
String email = user.getEmail();
|
||||
userRepository.delete(user);
|
||||
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
|
||||
Map.of("userId", userId.toString(), "email", email));
|
||||
}
|
||||
|
||||
public AppUser getById(UUID id) {
|
||||
@@ -141,7 +186,7 @@ public class UserService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
|
||||
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
|
||||
AppUser user = getById(id);
|
||||
|
||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||
@@ -166,13 +211,27 @@ public class UserService {
|
||||
}
|
||||
|
||||
if (dto.getGroupIds() != null) {
|
||||
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
||||
user.setGroups(groups);
|
||||
Set<UserGroup> before = new HashSet<>(user.getGroups());
|
||||
Set<UserGroup> after = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
||||
user.setGroups(after);
|
||||
groupChangePayload(before, after, id, user.getEmail())
|
||||
.ifPresent(payload -> auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, payload));
|
||||
}
|
||||
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
private Optional<Map<String, Object>> groupChangePayload(
|
||||
Set<UserGroup> before, Set<UserGroup> after, UUID userId, String email) {
|
||||
Set<UUID> beforeIds = before.stream().map(UserGroup::getId).collect(toSet());
|
||||
Set<UUID> afterIds = after.stream().map(UserGroup::getId).collect(toSet());
|
||||
if (beforeIds.equals(afterIds)) return Optional.empty();
|
||||
List<String> added = after.stream().filter(g -> !beforeIds.contains(g.getId())).map(UserGroup::getName).toList();
|
||||
List<String> removed = before.stream().filter(g -> !afterIds.contains(g.getId())).map(UserGroup::getName).toList();
|
||||
return Optional.of(Map.of("userId", userId.toString(), "email", email,
|
||||
"addedGroups", added, "removedGroups", removed));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
||||
AppUser user = getById(userId);
|
||||
|
||||
@@ -23,7 +23,8 @@ spring:
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB
|
||||
max-request-size: 50MB
|
||||
max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317
|
||||
file-size-threshold: 2KB
|
||||
|
||||
mail:
|
||||
host: ${MAIL_HOST:}
|
||||
|
||||
@@ -6,12 +6,19 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -47,4 +54,21 @@ class AuditLogQueryServiceTest {
|
||||
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
||||
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKinds() {
|
||||
AuditLog entry = AuditLog.builder().id(UUID.randomUUID()).kind(AuditKind.USER_CREATED).build();
|
||||
when(queryRepository.findByKindIn(anyCollection(), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(entry)));
|
||||
|
||||
List<AuditLog> result = auditLogQueryService.findRecentUserManagementEvents(5);
|
||||
|
||||
assertThat(result).containsExactly(entry);
|
||||
verify(queryRepository).findByKindIn(
|
||||
argThat((Collection<AuditKind> kinds) ->
|
||||
kinds.contains(AuditKind.USER_CREATED) &&
|
||||
kinds.contains(AuditKind.USER_DELETED) &&
|
||||
kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)),
|
||||
any(Pageable.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +1,17 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
@@ -69,7 +71,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns200_whenAuthenticated() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
@@ -79,13 +81,13 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_withStatusParam_passesItToService() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any());
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -112,12 +114,12 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseContainsTotalCount() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(0))
|
||||
.andExpect(jsonPath("$.totalElements").value(0))
|
||||
.andExpect(jsonPath("$.items").isArray());
|
||||
}
|
||||
|
||||
@@ -133,7 +135,7 @@ class DocumentControllerTest {
|
||||
.build();
|
||||
var matchData = new SearchMatchData(
|
||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||
@@ -143,6 +145,70 @@ class DocumentControllerTest {
|
||||
.value("Er schrieb einen langen Brief"));
|
||||
}
|
||||
|
||||
// ─── /api/documents/search pagination ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_responseExposesPagingFields() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.pageNumber").exists())
|
||||
.andExpect(jsonPath("$.pageSize").exists())
|
||||
.andExpect(jsonPath("$.totalPages").exists())
|
||||
.andExpect(jsonPath("$.totalElements").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenSizeExceedsMax() throws Exception {
|
||||
// Locks @Validated on the controller — removing it silently reopens the
|
||||
// DoS window where a client could request all 1500 docs + enrichment.
|
||||
mockMvc.perform(get("/api/documents/search").param("size", "101"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenSizeBelowMin() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/search").param("size", "0"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenPageNegative() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/search").param("page", "-1"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns400_whenPageAboveMax() throws Exception {
|
||||
// Guards against page * size overflow into negative SQL OFFSET
|
||||
mockMvc.perform(get("/api/documents/search").param("page", "200000"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_passesPageRequestToService() throws Exception {
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture());
|
||||
org.springframework.data.domain.Pageable pageable = captor.getValue();
|
||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
|
||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
|
||||
}
|
||||
|
||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -702,4 +768,476 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.editorName").value("Otto"));
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/quick-upload — metadata part ────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||
|
||||
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build();
|
||||
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build();
|
||||
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc1, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc2, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc3, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f3 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created.length()").value(3))
|
||||
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.updated").isEmpty())
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
||||
Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(existing, false));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception {
|
||||
Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
||||
Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
||||
Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build();
|
||||
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docA, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docB, true));
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(docC, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f3 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
||||
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
||||
.andExpect(jsonPath("$.created[2].title").value("Gamma"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
org.mockito.Mockito.doThrow(
|
||||
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
|
||||
.when(documentService).validateBatch(eq(2), any());
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile f1 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile f2 =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception {
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build();
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
org.mockito.ArgumentCaptor<DocumentBatchMetadataDTO> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class);
|
||||
when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any()))
|
||||
.thenReturn(new DocumentService.StoreResult(doc, true));
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1});
|
||||
org.springframework.mock.web.MockMultipartFile metadata =
|
||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
||||
.containsExactly("Briefwechsel", "Krieg");
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
org.mockito.Mockito.doThrow(
|
||||
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
|
||||
.when(documentService).validateBatch(eq(51), any());
|
||||
|
||||
var builder = multipart("/api/documents/quick-upload");
|
||||
for (int i = 0; i < 51; i++) {
|
||||
builder.file(new org.springframework.mock.web.MockMultipartFile(
|
||||
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
||||
}
|
||||
|
||||
mockMvc.perform(builder)
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||
}
|
||||
|
||||
// ─── PATCH /api/documents/bulk ───────────────────────────────────────────
|
||||
|
||||
private static String bulkBody(String... uuids) {
|
||||
StringBuilder sb = new StringBuilder("{\"documentIds\":[");
|
||||
for (int i = 0; i < uuids.length; i++) {
|
||||
if (i > 0) sb.append(",");
|
||||
sb.append("\"").append(uuids[i]).append("\"");
|
||||
}
|
||||
sb.append("]}");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(UUID.randomUUID().toString())))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void patchBulk_returns403_forReadAllUser() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(UUID.randomUUID().toString())))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"documentIds\":[]}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns400_whenDocumentIdsExceedsCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
String[] ids = new String[501];
|
||||
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(ids)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns400_whenArchiveBoxExceeds255Chars() throws Exception {
|
||||
// Tobias C2 — DocumentBulkEditDTO.archiveBox carries @Size(max=255).
|
||||
// Without @Valid on @RequestBody this would silently land an
|
||||
// arbitrarily long string; the test pins both the annotation and
|
||||
// the controller-level @Valid wiring.
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id = UUID.randomUUID();
|
||||
String tooLong = "x".repeat(256);
|
||||
|
||||
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_acceptsExactly500Ids_atTheCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||
|
||||
String[] ids = new String[500];
|
||||
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(ids)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(500));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentService.applyBulkEditToDocument(eq(id), any(), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(id).build());
|
||||
|
||||
// Same id sent three times — controller should dedupe and call the
|
||||
// service exactly once, returning updated=1, not 3.
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(id.toString(), id.toString(), id.toString())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(1));
|
||||
|
||||
verify(documentService, org.mockito.Mockito.times(1))
|
||||
.applyBulkEditToDocument(eq(id), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returns200_andCallsServiceForEachId() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(id1.toString(), id2.toString())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(2))
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
|
||||
verify(documentService).applyBulkEditToDocument(eq(id1), any(), any());
|
||||
verify(documentService).applyBulkEditToDocument(eq(id2), any(), any());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/ids ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getDocumentIds_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getDocumentIds_returns403_forUserWithoutWriteAll() throws Exception {
|
||||
// /ids is gated WRITE_ALL because it powers the bulk-edit "Alle X
|
||||
// editieren" fast path; no other consumer needs it.
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(List.of(id));
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0]").value(id.toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getDocumentIds_passesSenderIdParamToService() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID senderId = UUID.randomUUID();
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getDocumentIds_returns400_whenResultExceedsFilterCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
|
||||
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
|
||||
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(tooMany);
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/batch-metadata ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[]}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void batchMetadata_returns400_whenIdsExceedsCap() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
StringBuilder sb = new StringBuilder("{\"ids\":[");
|
||||
for (int i = 0; i < 501; i++) {
|
||||
if (i > 0) sb.append(",");
|
||||
sb.append("\"").append(UUID.randomUUID()).append("\"");
|
||||
}
|
||||
sb.append("]}");
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(sb.toString()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void batchMetadata_returnsSummaries_forExistingIds() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentService.batchMetadata(any())).thenReturn(List.of(
|
||||
new org.raddatz.familienarchiv.dto.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file")));
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\":[\"" + id + "\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||
.andExpect(jsonPath("$[0].title").value("Brief"))
|
||||
.andExpect(jsonPath("$[0].pdfUrl").value("/api/documents/" + id + "/file"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages() throws Exception {
|
||||
// Nora C4 — DocumentController.sanitizeForLog defends against
|
||||
// CWE-117 (log injection) by replacing CR/LF in any free-form string
|
||||
// it interpolates. Same helper now sanitises BulkEditError.message
|
||||
// before it round-trips to the frontend.
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID badId = UUID.randomUUID();
|
||||
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
|
||||
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
|
||||
"evil\r\nFAKE LOG ENTRY: admin logged in"));
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(badId.toString())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.errors[0].message",
|
||||
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\n"))))
|
||||
.andExpect(jsonPath("$.errors[0].message",
|
||||
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\r"))))
|
||||
.andExpect(jsonPath("$.errors[0].message",
|
||||
org.hamcrest.Matchers.containsString("evil_")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID okId = UUID.randomUUID();
|
||||
UUID badId = UUID.randomUUID();
|
||||
when(documentService.applyBulkEditToDocument(eq(okId), any(), any()))
|
||||
.thenAnswer(inv -> Document.builder().id(okId).build());
|
||||
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
|
||||
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
||||
|
||||
mockMvc.perform(patch("/api/documents/bulk")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(bulkBody(okId.toString(), badId.toString())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.updated").value(1))
|
||||
.andExpect(jsonPath("$.errors[0].id").value(badId.toString()))
|
||||
.andExpect(jsonPath("$.errors[0].message").value(
|
||||
org.hamcrest.Matchers.containsString("not found")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.Person;
|
||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||
@@ -25,6 +28,7 @@ import java.util.UUID;
|
||||
|
||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -183,19 +187,19 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
|
||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Müller\"}"))
|
||||
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
|
||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -204,7 +208,7 @@ class PersonControllerTest {
|
||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -213,7 +217,7 @@ class PersonControllerTest {
|
||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -225,11 +229,53 @@ class PersonControllerTest {
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.firstName").value("Hans"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns200_forInstitution_withoutFirstName() throws Exception {
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.lastName").value("Verlag GmbH"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_trimsTitle_beforePersisting() throws Exception {
|
||||
ArgumentCaptor<org.raddatz.familienarchiv.dto.PersonUpdateDTO> captor =
|
||||
ArgumentCaptor.forClass(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class);
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat(captor.getValue().getTitle()).isEqualTo("Prof.");
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createPerson_returns400_whenPersonTypeIsSkip() throws Exception {
|
||||
when(personService.createPerson(any())).thenThrow(
|
||||
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
||||
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"));
|
||||
}
|
||||
|
||||
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -242,10 +288,10 @@ class PersonControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
|
||||
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -254,7 +300,7 @@ class PersonControllerTest {
|
||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -267,7 +313,7 @@ class PersonControllerTest {
|
||||
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.lastName").value("Müller"));
|
||||
}
|
||||
@@ -317,11 +363,10 @@ class PersonControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||
// firstName valid, lastName blank → second || operand = true → 400
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -339,7 +384,7 @@ class PersonControllerTest {
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||
"\"notes\":\"Some notes\"}"))
|
||||
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.firstName").value("Maria"))
|
||||
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
||||
@@ -355,7 +400,7 @@ class PersonControllerTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -366,7 +411,7 @@ class PersonControllerTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@@ -377,7 +422,7 @@ class PersonControllerTest {
|
||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/persons")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@@ -386,7 +431,7 @@ class PersonControllerTest {
|
||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@@ -104,4 +106,55 @@ class UserControllerTest {
|
||||
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// ─── permission enforcement ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "reader@example.com")
|
||||
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "reader@example.com")
|
||||
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "reader@example.com")
|
||||
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ─── unauthenticated access ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createUser_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/users")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -45,6 +46,31 @@ class DashboardServiceTest {
|
||||
|
||||
@InjectMocks DashboardService dashboardService;
|
||||
|
||||
// ─── getResume wires thumbnailUrl from Document ───────────────────────────
|
||||
|
||||
@Test
|
||||
void getResume_populatesThumbnailUrl_fromDocument() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
UUID docId = UUID.fromString("12345678-aaaa-bbbb-cccc-1234567890ab");
|
||||
|
||||
Document doc = Document.builder()
|
||||
.id(docId).title("Brief").originalFilename("brief.pdf")
|
||||
.thumbnailKey("thumbnails/" + docId + ".jpg")
|
||||
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
|
||||
when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId));
|
||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||
when(transcriptionService.listBlocks(docId)).thenReturn(List.of());
|
||||
|
||||
DashboardResumeDTO result = dashboardService.getResume(userId);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.thumbnailUrl()).isEqualTo(doc.getThumbnailUrl());
|
||||
assertThat(result.thumbnailUrl()).startsWith("/api/documents/" + docId + "/thumbnail?v=");
|
||||
}
|
||||
|
||||
// ─── toActorDTO (via getResume collaborators) ─────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -24,10 +25,43 @@ class DocumentSearchResultTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void of_total_equals_list_size() {
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
|
||||
void of_totalElements_equals_list_size_for_unpaged_shortcut() {
|
||||
DocumentSearchResult result = DocumentSearchResult.of(
|
||||
List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
|
||||
|
||||
assertThat(result.total()).isEqualTo(2L);
|
||||
assertThat(result.totalElements()).isEqualTo(2L);
|
||||
assertThat(result.pageNumber()).isZero();
|
||||
assertThat(result.pageSize()).isEqualTo(2);
|
||||
assertThat(result.totalPages()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void of_empty_shortcut_has_zero_totalPages() {
|
||||
DocumentSearchResult result = DocumentSearchResult.of(List.of());
|
||||
|
||||
assertThat(result.totalElements()).isZero();
|
||||
assertThat(result.totalPages()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void paged_factory_populates_paging_fields_from_pageable_and_total() {
|
||||
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
|
||||
|
||||
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
|
||||
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
||||
}
|
||||
|
||||
@Test
|
||||
void paged_factory_totalPages_rounds_up_on_remainder() {
|
||||
DocumentSearchResult result =
|
||||
DocumentSearchResult.paged(List.of(), PageRequest.of(0, 7), 30L);
|
||||
|
||||
assertThat(result.totalPages()).isEqualTo(5); // ceil(30 / 7)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -53,9 +87,18 @@ class DocumentSearchResultTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
|
||||
void totalElements_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("totalElements").getAnnotation(Schema.class);
|
||||
assertThat(schema).isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void paging_components_are_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
for (String name : List.of("pageNumber", "pageSize", "totalPages")) {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField(name).getAnnotation(Schema.class);
|
||||
assertThat(schema).as(name + " must have @Schema").isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DocumentTest {
|
||||
|
||||
@Test
|
||||
void getThumbnailUrl_returnsNull_whenThumbnailKeyNull() {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.thumbnailKey(null)
|
||||
.build();
|
||||
|
||||
assertThat(doc.getThumbnailUrl()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getThumbnailUrl_omitsCacheBuster_whenThumbnailKeyPresentButGeneratedAtNull() {
|
||||
UUID id = UUID.fromString("11111111-2222-3333-4444-555555555555");
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.thumbnailKey("thumbnails/" + id + ".jpg")
|
||||
.thumbnailGeneratedAt(null)
|
||||
.build();
|
||||
|
||||
assertThat(doc.getThumbnailUrl())
|
||||
.isEqualTo("/api/documents/" + id + "/thumbnail");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getThumbnailUrl_includesCacheBuster_whenBothKeyAndGeneratedAtPresent() {
|
||||
UUID id = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
LocalDateTime generatedAt = LocalDateTime.of(2026, 4, 23, 14, 30, 45);
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.thumbnailKey("thumbnails/" + id + ".jpg")
|
||||
.thumbnailGeneratedAt(generatedAt)
|
||||
.build();
|
||||
|
||||
// frontend equivalent: `?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}`
|
||||
// where thumbnailGeneratedAt is the ISO-8601 string Jackson serialises.
|
||||
// LocalDateTime.toString() produces "2026-04-23T14:30:45"; encodeURIComponent
|
||||
// turns ":" into "%3A" but leaves "T" and digits alone.
|
||||
String expected = "/api/documents/" + id + "/thumbnail?v=2026-04-23T14%3A30%3A45";
|
||||
assertThat(doc.getThumbnailUrl()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void thumbnailUrl_isSerialisedToJson_soFrontendReceivesIt() throws Exception {
|
||||
UUID id = UUID.fromString("99999999-aaaa-bbbb-cccc-111122223333");
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Brief")
|
||||
.originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.thumbnailKey("thumbnails/" + id + ".jpg")
|
||||
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
|
||||
.build();
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
String json = mapper.writeValueAsString(doc);
|
||||
|
||||
// Locks the wire contract, not just the Java API: every Document JSON must carry
|
||||
// `thumbnailUrl`. Protects against silent breakage if the getter gets renamed,
|
||||
// hidden behind @JsonIgnore, or visibility-reduced — any of which would leave the
|
||||
// frontend rendering the fallback icon on every surface.
|
||||
assertThat(json).contains("\"thumbnailUrl\":\"" + doc.getThumbnailUrl() + "\"");
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,22 @@ class DocumentFtsTest {
|
||||
assertThat(ids).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word() {
|
||||
// "Wille" stems to "will" via the German Snowball stemmer.
|
||||
// "will" is also a German stop word, so to_tsquery('german','will:*') drops it.
|
||||
// The prefix-transform step must use to_tsquery('simple',...) to avoid this.
|
||||
Document doc = documentRepository.saveAndFlush(document("Foto"));
|
||||
UUID annotationId = annotation(doc.getId());
|
||||
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Der Wille des Volkes", 0));
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
|
||||
|
||||
assertThat(ids).contains(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
||||
documentRepository.saveAndFlush(document("Brief"));
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
|
||||
* Specification→Pageable→Page→DTO path that unit tests mock around. Seeds 120
|
||||
* UPLOADED documents and asserts the slice/total/totalPages arithmetic holds
|
||||
* against the actual JPA query.
|
||||
*
|
||||
* <p>Closes the integration-coverage gap Sara flagged on PR #316.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class DocumentSearchPagedIntegrationTest {
|
||||
|
||||
private static final int FIXTURE_SIZE = 120;
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
// Deterministic date spread so DATE-DESC order is predictable:
|
||||
// document #0 has the oldest date, document #119 has the newest.
|
||||
for (int i = 0; i < FIXTURE_SIZE; i++) {
|
||||
Document doc = Document.builder()
|
||||
.title("Dok-" + String.format("%03d", i))
|
||||
.originalFilename("dok-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.documentDate(LocalDate.of(1900, 1, 1).plusDays(i))
|
||||
.build();
|
||||
documentRepository.save(doc);
|
||||
}
|
||||
assertThat(documentRepository.count()).isEqualTo(FIXTURE_SIZE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.items()).hasSize(50);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
assertThat(result.pageNumber()).isZero();
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_lastPartialPage_returnsRemainingItems() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(2, 50));
|
||||
|
||||
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
|
||||
assertThat(result.items()).hasSize(20);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
assertThat(result.pageNumber()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(99, 50));
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_senderSort_pageOne_slicesInMemory_withCorrectTotal() {
|
||||
// SENDER sort path fetches all + sorts + slices in-memory (see scaling
|
||||
// comment in DocumentService). Proves that the in-memory slice path
|
||||
// returns the correct total from a real repository fetch.
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.SENDER, "asc", null,
|
||||
PageRequest.of(1, 50));
|
||||
|
||||
assertThat(result.items()).hasSize(50);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
assertThat(result.totalPages()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_differentPagesReturnDisjointSlices() {
|
||||
DocumentSearchResult page0 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(0, 50));
|
||||
DocumentSearchResult page1 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null,
|
||||
PageRequest.of(1, 50));
|
||||
|
||||
// No document id should appear on both pages — slicing must be exclusive.
|
||||
var idsOnPage0 = page0.items().stream()
|
||||
.map(item -> item.document().getId())
|
||||
.toList();
|
||||
var idsOnPage1 = page1.items().stream()
|
||||
.map(item -> item.document().getId())
|
||||
.toList();
|
||||
for (UUID id : idsOnPage0) {
|
||||
assertThat(idsOnPage1).doesNotContain(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
import java.time.LocalDate;
|
||||
@@ -25,6 +26,8 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentServiceSortTest {
|
||||
|
||||
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
||||
|
||||
@Mock DocumentRepository documentRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock FileService fileService;
|
||||
@@ -51,12 +54,12 @@ class DocumentServiceSortTest {
|
||||
|
||||
// FTS returns id1 first (higher rank), id2 second
|
||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||
// findAll(spec, sort) — the correct date path — returns date-DESC order
|
||||
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(newer, older));
|
||||
// findAll(spec, pageable) — the correct date path — returns date-DESC order
|
||||
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
|
||||
|
||||
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||
assertThat(result.items()).hasSize(2);
|
||||
@@ -78,7 +81,7 @@ class DocumentServiceSortTest {
|
||||
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
|
||||
// Expect: rank order restored (id1 first)
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
@@ -97,7 +100,7 @@ class DocumentServiceSortTest {
|
||||
.thenReturn(List.of(doc2, doc1));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, null, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||
|
||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.dto.MatchOffset;
|
||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||
@@ -24,6 +25,7 @@ import org.raddatz.familienarchiv.model.Tag;
|
||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
@@ -46,6 +48,12 @@ import static org.mockito.Mockito.*;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DocumentServiceTest {
|
||||
|
||||
// Used by tests that don't care about paging. 10 000 is chosen large enough
|
||||
// to hold any fixture in this file but small enough that totalPages math
|
||||
// stays in int range. Swap to `PageRequest.of(0, 10_000)` elsewhere is a
|
||||
// red flag — use this constant.
|
||||
private static final Pageable UNPAGED = PageRequest.of(0, 10_000);
|
||||
|
||||
@Mock DocumentRepository documentRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock FileService fileService;
|
||||
@@ -113,6 +121,23 @@ class DocumentServiceTest {
|
||||
.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 ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -1323,26 +1348,124 @@ class DocumentServiceTest {
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
|
||||
// ─── searchDocuments — pagination ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchDocuments_fastPath_usesFindAllWithPageable_notWithSort() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_fastPath_propagatesPageableToDatabase() {
|
||||
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(3, 25));
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
|
||||
assertThat(captor.getValue().getPageSize()).isEqualTo(25);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_fastPath_returnsPageableTotalsOnResult() {
|
||||
// The service MUST report the full match count from Page.getTotalElements(),
|
||||
// not the slice size — otherwise the frontend's "N Briefe gefunden" label is wrong.
|
||||
Document d = Document.builder().id(UUID.randomUUID()).title("T").build();
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
||||
org.springframework.data.domain.PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isZero();
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3); // ceil(120/50)
|
||||
assertThat(result.items()).hasSize(1); // only the slice is enriched
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
|
||||
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
|
||||
// back with totalElements = 120.
|
||||
List<Document> all = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 120; i++) {
|
||||
Person p = Person.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.firstName("F" + i)
|
||||
.lastName(String.format("L%03d", i))
|
||||
.build();
|
||||
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
||||
}
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
||||
org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
assertThat(result.pageSize()).isEqualTo(50);
|
||||
assertThat(result.totalPages()).isEqualTo(3);
|
||||
assertThat(result.items()).hasSize(50);
|
||||
// Page 1 (offset 50) under ascending sender sort should start at L050
|
||||
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_pageBeyondLast_returnsEmptyContentAndCorrectTotal() {
|
||||
// Guards the JPA edge case where page * size > totalElements.
|
||||
// Must not throw, must return empty content + correct totalElements.
|
||||
List<Document> all = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 30; i++) {
|
||||
Person p = Person.builder().id(UUID.randomUUID()).lastName(String.format("L%02d", i)).build();
|
||||
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
||||
}
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
||||
org.springframework.data.domain.PageRequest.of(10, 50));
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
assertThat(result.totalElements()).isEqualTo(30L);
|
||||
}
|
||||
|
||||
// ─── searchDocuments — status filter ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void searchDocuments_passesStatusSpecificationToRepository() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null);
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, UNPAGED);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null);
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||
}
|
||||
|
||||
// ─── getRecentActivity ────────────────────────────────────────────────────
|
||||
@@ -1418,7 +1541,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(withSender, noSender));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
|
||||
assertThat(result.items()).hasSize(2);
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
||||
@@ -1438,7 +1561,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(noReceivers, withReceiver));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
||||
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
.containsExactly("Has Receiver", "No Receivers");
|
||||
@@ -1460,7 +1583,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(docNullName, docSmith));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||
|
||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||
@@ -1482,7 +1605,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
|
||||
assertThat(result.items()).hasSize(1);
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
@@ -1492,11 +1615,12 @@ class DocumentServiceTest {
|
||||
|
||||
@Test
|
||||
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null, null, null, null);
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
UNPAGED);
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
@@ -1515,7 +1639,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
||||
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||
@@ -1707,4 +1831,437 @@ class DocumentServiceTest {
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
||||
}
|
||||
|
||||
// ─── storeDocumentWithBatchMetadata ──────────────────────────────────────
|
||||
|
||||
private MockMultipartFile pdfFile(String name) {
|
||||
return new MockMultipartFile("file", name, "application/pdf", new byte[]{1});
|
||||
}
|
||||
|
||||
private void stubStoreDocument(String filename) throws Exception {
|
||||
when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.empty());
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_appliesTitleByIndex() throws Exception {
|
||||
stubStoreDocument("scan01.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTitles(List.of("Erster Brief", "Zweiter Brief"));
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan01.pdf"), meta, 0, null);
|
||||
|
||||
assertThat(result.document().getTitle()).isEqualTo("Erster Brief");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_resolvesSenderViaPersonService() throws Exception {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
stubStoreDocument("scan02.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
Person sender = Person.builder().id(senderId).firstName("Anna").build();
|
||||
when(personService.getById(senderId)).thenReturn(sender);
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setSenderId(senderId);
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan02.pdf"), meta, 0, null);
|
||||
|
||||
assertThat(result.document().getSender()).isEqualTo(sender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_appliesTagsViaUpdateDocumentTags() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
when(documentRepository.findFirstByOriginalFilename("scan03.pdf")).thenReturn(Optional.empty());
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||
Document d = inv.getArgument(0);
|
||||
if (d.getId() == null) d.setId(docId);
|
||||
return d;
|
||||
});
|
||||
when(documentRepository.findById(docId)).thenAnswer(inv -> {
|
||||
Document d = new Document();
|
||||
d.setId(docId);
|
||||
return Optional.of(d);
|
||||
});
|
||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTagNames(List.of("Familie"));
|
||||
|
||||
documentService.storeDocumentWithBatchMetadata(pdfFile("scan03.pdf"), meta, 0, null);
|
||||
|
||||
verify(tagService).findOrCreate("Familie");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocumentWithBatchMetadata_leavesTitle_whenIndexExceedsTitlesList() throws Exception {
|
||||
stubStoreDocument("scan04.pdf");
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
meta.setTitles(List.of("Only One Title"));
|
||||
|
||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan04.pdf"), meta, 5, null);
|
||||
|
||||
assertThat(result.document().getTitle()).isEqualTo("scan04");
|
||||
}
|
||||
|
||||
// ─── validateBatch ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() {
|
||||
assertThatThrownBy(() -> documentService.validateBatch(51, null))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("50");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() {
|
||||
documentService.validateBatch(50, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() {
|
||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO metadata =
|
||||
new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
||||
metadata.setTitles(java.util.List.of("A", "B", "C"));
|
||||
|
||||
assertThatThrownBy(() -> documentService.validateBatch(2, metadata))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("titles");
|
||||
}
|
||||
|
||||
// ─── applyBulkEditToDocument ─────────────────────────────────────────────
|
||||
|
||||
private static org.raddatz.familienarchiv.dto.DocumentBulkEditDTO bulkDto() {
|
||||
return new org.raddatz.familienarchiv.dto.DocumentBulkEditDTO();
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_throwsNotFound_whenDocumentMissing() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto(), null))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(id.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_appliesTagsAdditively_preservesExistingTags() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Tag added = Tag.builder().id(UUID.randomUUID()).name("Kurrent").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(tagService.findOrCreate("Kurrent")).thenReturn(added);
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setTagNames(List.of("Kurrent"));
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsTags_whenTagNamesIsNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.applyBulkEditToDocument(id, bulkDto(), null);
|
||||
|
||||
assertThat(doc.getTags()).containsExactly(existing);
|
||||
verify(tagService, never()).findOrCreate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsTags_whenTagNamesIsEmpty() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.tags(new HashSet<>(Set.of(existing)))
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setTagNames(List.of());
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getTags()).containsExactly(existing);
|
||||
verify(tagService, never()).findOrCreate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_replacesSender_whenSenderIdProvided() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID senderId = UUID.randomUUID();
|
||||
Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Person newSender = Person.builder().id(senderId).firstName("New").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.sender(oldSender)
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(personService.getById(senderId)).thenReturn(newSender);
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setSenderId(senderId);
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getSender()).isEqualTo(newSender);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsSender_whenSenderIdIsNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("X").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.sender(existing)
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
documentService.applyBulkEditToDocument(id, bulkDto(), null);
|
||||
|
||||
assertThat(doc.getSender()).isEqualTo(existing);
|
||||
verify(personService, never()).getById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_addsReceiversAdditively_preservesExistingReceivers() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID newReceiverId = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Person added = Person.builder().id(newReceiverId).firstName("New").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.receivers(new HashSet<>(Set.of(existing)))
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
when(personService.getAllById(List.of(newReceiverId))).thenReturn(List.of(added));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setReceiverIds(List.of(newReceiverId));
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsReceivers_whenReceiverIdsIsNullOrEmpty() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.receivers(new HashSet<>(Set.of(existing)))
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setReceiverIds(List.of());
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getReceivers()).containsExactly(existing);
|
||||
verify(personService, never()).getAllById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID actorId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenReturn(doc);
|
||||
|
||||
documentService.applyBulkEditToDocument(id, bulkDto(), actorId);
|
||||
|
||||
verify(documentVersionService).recordVersion(doc);
|
||||
verify(auditService).logAfterCommit(
|
||||
eq(AuditKind.METADATA_UPDATED),
|
||||
eq(actorId),
|
||||
eq(id),
|
||||
eq(java.util.Map.of("source", "BULK_EDIT")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.archiveBox("OldBox")
|
||||
.archiveFolder("OldFolder")
|
||||
.documentLocation("OldLocation")
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setArchiveBox("NewBox");
|
||||
dto.setArchiveFolder("NewFolder");
|
||||
dto.setDocumentLocation("NewLocation");
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getArchiveBox()).isEqualTo("NewBox");
|
||||
assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder");
|
||||
assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable() {
|
||||
// Sara C1 — unresolvable sender flows up as a per-document error chip
|
||||
// rather than aborting the controller's batch loop.
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID unknownSender = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(personService.getById(unknownSender))
|
||||
.thenThrow(DomainException.notFound(
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.PERSON_NOT_FOUND,
|
||||
"Person not found: " + unknownSender));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setSenderId(unknownSender);
|
||||
|
||||
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, dto, null))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(unknownSender.toString());
|
||||
}
|
||||
|
||||
// ─── findIdsForFilter ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findIdsForFilter_returnsAllMatchingIds_uncapped() {
|
||||
Document d1 = Document.builder().id(UUID.randomUUID()).title("A").build();
|
||||
Document d2 = Document.builder().id(UUID.randomUUID()).title("B").build();
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of(d1, d2));
|
||||
|
||||
List<UUID> result = documentService.findIdsForFilter(
|
||||
null, null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result).containsExactly(d1.getId(), d2.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec() {
|
||||
// Sara C3 — tagOp=OR flips useOrLogic at the spec layer; without a
|
||||
// test pinning this, a refactor that wired OR to AND (or vice versa)
|
||||
// would slip through.
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of());
|
||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||
|
||||
documentService.findIdsForFilter(
|
||||
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR);
|
||||
|
||||
// Spec built without throwing → OR branch was exercised. Coverage gain
|
||||
// is in not-throwing on the OR-specific code path; the actual SQL is
|
||||
// covered by JPA itself.
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
|
||||
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
||||
|
||||
List<UUID> result = documentService.findIdsForFilter(
|
||||
"xyz", null, null, null, null, null, null, null, null);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
||||
}
|
||||
|
||||
// ─── batchMetadata ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void batchMetadata_returnsEmpty_whenIdsIsNull() {
|
||||
assertThat(documentService.batchMetadata(null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_returnsEmpty_whenIdsIsEmpty() {
|
||||
assertThat(documentService.batchMetadata(List.of())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_returnsSummariesWithPdfUrl_forExistingIds() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
Document d1 = Document.builder().id(id1).title("Brief 1").build();
|
||||
Document d2 = Document.builder().id(id2).title("Brief 2").build();
|
||||
when(documentRepository.findAllById(List.of(id1, id2))).thenReturn(List.of(d1, d2));
|
||||
|
||||
var result = documentService.batchMetadata(List.of(id1, id2));
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result.get(0).id()).isEqualTo(id1);
|
||||
assertThat(result.get(0).title()).isEqualTo("Brief 1");
|
||||
assertThat(result.get(0).pdfUrl()).isEqualTo("/api/documents/" + id1 + "/file");
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_silentlyDropsUnknownIds() {
|
||||
UUID known = UUID.randomUUID();
|
||||
UUID missing = UUID.randomUUID();
|
||||
Document d = Document.builder().id(known).title("Found").build();
|
||||
when(documentRepository.findAllById(List.of(known, missing))).thenReturn(List.of(d));
|
||||
|
||||
var result = documentService.batchMetadata(List.of(known, missing));
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).id()).isEqualTo(known);
|
||||
}
|
||||
|
||||
@Test
|
||||
void batchMetadata_fallsBackToOriginalFilename_whenTitleIsNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document d = Document.builder().id(id).originalFilename("scan001.pdf").build();
|
||||
when(documentRepository.findAllById(List.of(id))).thenReturn(List.of(d));
|
||||
|
||||
var result = documentService.batchMetadata(List.of(id));
|
||||
|
||||
assertThat(result.get(0).title()).isEqualTo("scan001.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("T")
|
||||
.archiveBox("KeepBox")
|
||||
.archiveFolder("KeepFolder")
|
||||
.documentLocation("KeepLocation")
|
||||
.receivers(new HashSet<>())
|
||||
.build();
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var dto = bulkDto();
|
||||
dto.setArchiveBox(" ");
|
||||
dto.setArchiveFolder("");
|
||||
// documentLocation left null
|
||||
documentService.applyBulkEditToDocument(id, dto, null);
|
||||
|
||||
assertThat(doc.getArchiveBox()).isEqualTo("KeepBox");
|
||||
assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");
|
||||
assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,43 @@ class PersonServiceTest {
|
||||
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
||||
}
|
||||
|
||||
// ─── personType + title in createPerson(PersonUpdateDTO) ─────────────────
|
||||
|
||||
@Test
|
||||
void createPerson_dto_persistsPersonType() {
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Walter"); dto.setLastName("de Gruyter"); dto.setPersonType(PersonType.INSTITUTION);
|
||||
|
||||
Person result = personService.createPerson(dto);
|
||||
|
||||
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPerson_dto_throwsInvalidPersonType_whenSkip() {
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.SKIP);
|
||||
|
||||
assertThatThrownBy(() -> personService.createPerson(dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPerson_dto_persistsTitle() {
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Dr."); dto.setLastName("Müller"); dto.setTitle("Prof."); dto.setPersonType(PersonType.PERSON);
|
||||
|
||||
Person result = personService.createPerson(dto);
|
||||
|
||||
assertThat(result.getTitle()).isEqualTo("Prof.");
|
||||
}
|
||||
|
||||
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -145,6 +182,36 @@ class PersonServiceTest {
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
// ─── updatePerson (personType) ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsInvalidPersonType_whenSkip() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.SKIP);
|
||||
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_persistsPersonType() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").personType(PersonType.PERSON).build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.INSTITUTION);
|
||||
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||
}
|
||||
|
||||
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||
@@ -34,6 +37,7 @@ class UserServiceTest {
|
||||
@Mock AppUserRepository userRepository;
|
||||
@Mock UserGroupRepository groupRepository;
|
||||
@Mock PasswordEncoder passwordEncoder;
|
||||
@Mock AuditService auditService;
|
||||
@InjectMocks UserService userService;
|
||||
|
||||
// ─── findByEmail ──────────────────────────────────────────────────────────
|
||||
@@ -61,7 +65,7 @@ class UserServiceTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.deleteUser(id))
|
||||
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@@ -71,7 +75,7 @@ class UserServiceTest {
|
||||
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||
|
||||
userService.deleteUser(id);
|
||||
userService.deleteUser(UUID.randomUUID(), id);
|
||||
|
||||
verify(userRepository).delete(user);
|
||||
}
|
||||
@@ -90,7 +94,7 @@ class UserServiceTest {
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
AppUser result = userService.createUserOrUpdate(req);
|
||||
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(userRepository).save(any());
|
||||
@@ -108,7 +112,7 @@ class UserServiceTest {
|
||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||
when(userRepository.save(any())).thenReturn(existing);
|
||||
|
||||
userService.createUserOrUpdate(req);
|
||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||
|
||||
verify(userRepository, times(1)).save(existing);
|
||||
}
|
||||
@@ -229,7 +233,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getFirstName()).isEqualTo("Ada");
|
||||
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
||||
@@ -246,7 +250,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setFirstName("Ada");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getGroups()).containsExactly(adminGroup);
|
||||
}
|
||||
@@ -264,7 +268,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setGroupIds(List.of(newGroup.getId()));
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getGroups()).containsExactly(newGroup);
|
||||
}
|
||||
@@ -281,7 +285,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setGroupIds(List.of());
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getGroups()).isEmpty();
|
||||
}
|
||||
@@ -313,7 +317,7 @@ class UserServiceTest {
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
AppUser result = userService.createUserOrUpdate(req);
|
||||
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(groupRepository).findAllById(List.of(group.getId()));
|
||||
@@ -378,7 +382,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setNewPassword("newSecret");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getPassword()).isEqualTo("newHashed");
|
||||
}
|
||||
@@ -393,7 +397,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setNewPassword(" ");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getPassword()).isEqualTo("original");
|
||||
verify(passwordEncoder, never()).encode(any());
|
||||
@@ -408,7 +412,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setEmail(" ");
|
||||
|
||||
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
||||
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("blank");
|
||||
}
|
||||
@@ -425,7 +429,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setEmail("taken@example.com");
|
||||
|
||||
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
||||
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("E-Mail");
|
||||
}
|
||||
@@ -497,7 +501,7 @@ class UserServiceTest {
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
userService.createUserOrUpdate(req);
|
||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||
|
||||
verify(groupRepository, never()).findAllById(any());
|
||||
}
|
||||
@@ -561,7 +565,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setContact(null);
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getContact()).isNull();
|
||||
}
|
||||
@@ -576,7 +580,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setContact(" ");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getContact()).isNull();
|
||||
}
|
||||
@@ -591,7 +595,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setContact(" phone: 555 ");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getContact()).isEqualTo("phone: 555");
|
||||
}
|
||||
@@ -606,7 +610,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setEmail(null);
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
|
||||
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
||||
}
|
||||
@@ -622,7 +626,7 @@ class UserServiceTest {
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setEmail("me@example.com");
|
||||
|
||||
AppUser result = userService.adminUpdateUser(id, dto);
|
||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
||||
}
|
||||
|
||||
@@ -640,7 +644,7 @@ class UserServiceTest {
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
userService.createUserOrUpdate(req);
|
||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||
|
||||
verify(groupRepository, never()).findAllById(any());
|
||||
}
|
||||
@@ -699,6 +703,160 @@ class UserServiceTest {
|
||||
assertThat(result).containsExactly(g);
|
||||
}
|
||||
|
||||
// ─── audit: GROUP_MEMBERSHIP_CHANGED ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void adminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").permissions(Set.of("READ_ALL")).build();
|
||||
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").permissions(Set.of("WRITE_ALL")).build();
|
||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(oldGroup)).build();
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup));
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setGroupIds(List.of(newGroup.getId()));
|
||||
|
||||
userService.adminUpdateUser(actorId, userId, dto);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||
verify(auditService).logAfterCommit(
|
||||
org.mockito.ArgumentMatchers.eq(AuditKind.GROUP_MEMBERSHIP_CHANGED),
|
||||
org.mockito.ArgumentMatchers.eq(actorId),
|
||||
org.mockito.ArgumentMatchers.isNull(),
|
||||
payloadCaptor.capture());
|
||||
java.util.Map<String, Object> payload = payloadCaptor.getValue();
|
||||
assertThat(payload).containsEntry("email", "u@example.com");
|
||||
assertThat((java.util.List<String>) payload.get("addedGroups")).containsExactly("Editors");
|
||||
assertThat((java.util.List<String>) payload.get("removedGroups")).containsExactly("Viewers");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchanged() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
dto.setGroupIds(List.of(group.getId()));
|
||||
|
||||
userService.adminUpdateUser(actorId, userId, dto);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNull() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||
// groupIds not set → null
|
||||
|
||||
userService.adminUpdateUser(actorId, userId, dto);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── audit: USER_DELETED ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteUser_logsUserDeleted_withEmailInPayload() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(userId).email("gone@example.com").build();
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||
|
||||
userService.deleteUser(actorId, userId);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||
verify(auditService).logAfterCommit(
|
||||
org.mockito.ArgumentMatchers.eq(AuditKind.USER_DELETED),
|
||||
org.mockito.ArgumentMatchers.eq(actorId),
|
||||
org.mockito.ArgumentMatchers.isNull(),
|
||||
payloadCaptor.capture());
|
||||
assertThat(payloadCaptor.getValue()).containsEntry("email", "gone@example.com");
|
||||
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
||||
}
|
||||
|
||||
// ─── audit: USER_CREATED ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createUserOrUpdate_logsUserCreated_whenUserIsNew() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
CreateUserRequest req = new CreateUserRequest();
|
||||
req.setEmail("new@example.com");
|
||||
req.setInitialPassword("secret");
|
||||
req.setGroupIds(List.of());
|
||||
|
||||
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
|
||||
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
userService.createUserOrUpdate(actorId, req);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||
verify(auditService).logAfterCommit(
|
||||
org.mockito.ArgumentMatchers.eq(AuditKind.USER_CREATED),
|
||||
org.mockito.ArgumentMatchers.eq(actorId),
|
||||
org.mockito.ArgumentMatchers.isNull(),
|
||||
payloadCaptor.capture());
|
||||
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
||||
assertThat(payloadCaptor.getValue()).containsEntry("email", "new@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
CreateUserRequest req = new CreateUserRequest();
|
||||
req.setEmail("existing@example.com");
|
||||
req.setInitialPassword("pass");
|
||||
req.setGroupIds(List.of());
|
||||
|
||||
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build();
|
||||
when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing));
|
||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||
when(userRepository.save(any())).thenReturn(existing);
|
||||
|
||||
userService.createUserOrUpdate(actorId, req);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── createUserForBootstrap ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void createUserForBootstrap_createsUserWithoutAuditEvent() {
|
||||
CreateUserRequest req = new CreateUserRequest();
|
||||
req.setEmail("bootstrap@example.com");
|
||||
req.setInitialPassword("secret");
|
||||
req.setGroupIds(List.of());
|
||||
|
||||
when(userRepository.findByEmail("bootstrap@example.com")).thenReturn(Optional.empty());
|
||||
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("bootstrap@example.com").build();
|
||||
when(userRepository.save(any())).thenReturn(saved);
|
||||
|
||||
AppUser result = userService.createUserForBootstrap(req);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── createGroup ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
996
docs/specs/bulk-upload-concepts.html
Normal file
996
docs/specs/bulk-upload-concepts.html
Normal file
@@ -0,0 +1,996 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Bulk Upload — 3 Concept Designs · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;1,300&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
/* ── Reset ── */
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
|
||||
.doc{max-width:1300px;margin:0 auto;padding:48px 32px 120px}
|
||||
|
||||
/* ── Masthead ── */
|
||||
.mh{padding-bottom:24px;border-bottom:3px solid #002850;margin-bottom:60px}
|
||||
.mh .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
|
||||
.mh h1{font-size:28px;font-weight:900;color:#002850;letter-spacing:-.4px;margin-top:6px}
|
||||
.mh p{font-size:13px;color:#555;max-width:780px;line-height:1.75;margin-top:10px}
|
||||
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:14px}
|
||||
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
|
||||
.tag{background:#002850;color:#A6DAD8;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
|
||||
.tag.amber{background:#7c4a00;color:#fde68a}
|
||||
.tag.green{background:#1e5e34;color:#d1fae5}
|
||||
.tag.gray{background:#4b5563;color:#e5e7eb}
|
||||
.tag.mint{background:#A6DAD8;color:#002850}
|
||||
|
||||
/* ── Goals card ── */
|
||||
.goals{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:22px 26px;margin:0 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.goals h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:14px}
|
||||
.goals ul{list-style:none;display:grid;grid-template-columns:1fr 1fr;gap:10px 28px}
|
||||
.goals li{font-size:12.5px;color:#333;padding-left:20px;position:relative;line-height:1.55}
|
||||
.goals li::before{content:"→";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||
|
||||
/* ── Concept section ── */
|
||||
.concept{margin-bottom:88px;padding-bottom:88px;border-bottom:2px dashed #C8C4BE}
|
||||
.concept:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||
.concept-header{display:flex;align-items:flex-start;gap:24px;margin-bottom:36px}
|
||||
.concept-num{font-size:84px;font-weight:900;color:#E0DDD6;line-height:1;flex-shrink:0;width:96px}
|
||||
.concept-label{font-size:8.5px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#A6DAD8;margin-bottom:5px}
|
||||
.concept-title{font-family:'Merriweather',Georgia,serif;font-size:24px;font-weight:700;color:#002850;margin-bottom:10px}
|
||||
.concept-desc{font-size:13.5px;color:#555;max-width:740px;line-height:1.75}
|
||||
.concept-best{margin-top:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.best-label{background:#A6DAD8;color:#002850;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
|
||||
.best-text{font-size:12px;font-weight:600;color:#444}
|
||||
.concept-tradeoff{margin-top:8px;font-size:12px;color:#888;font-style:italic;max-width:680px;line-height:1.7}
|
||||
|
||||
/* ── Browser chrome ── */
|
||||
.screen{max-width:980px;margin:0 auto}
|
||||
.screen.narrow{max-width:400px}
|
||||
.chrome{background:#F5F4EE;border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
|
||||
.chrome-bar{height:22px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 9px;gap:5px;flex-shrink:0}
|
||||
.chrome-dot{width:7px;height:7px;border-radius:50%;background:#BDB8B1}
|
||||
.chrome-url{flex:1;height:10px;background:#CCC8C2;border-radius:5px;margin-left:8px}
|
||||
.viewport-hint{font-size:7.5px;font-weight:800;color:#A6DAD8;letter-spacing:1px;text-transform:uppercase;padding:4px 9px;background:#002850;border-radius:2px;margin-left:8px}
|
||||
|
||||
/* ── App nav ── */
|
||||
.app-nav{height:32px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:12px;flex-shrink:0}
|
||||
.app-logo{font-family:'Merriweather',Georgia,serif;font-size:8px;font-weight:700;color:#fff;border-bottom:2px solid #A6DAD8;padding-bottom:1px}
|
||||
.app-link{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.45);white-space:nowrap}
|
||||
.app-link.on{color:rgba(255,255,255,.9)}
|
||||
.app-nav-r{margin-left:auto;display:flex;gap:8px;align-items:center}
|
||||
.app-avatar{width:18px;height:18px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:rgba(255,255,255,.5)}
|
||||
|
||||
/* ── Common form element styles ── */
|
||||
.f-label{font-size:6.5px;font-weight:700;color:#666;letter-spacing:.2px;text-transform:uppercase}
|
||||
.f-req{color:#C0392B}
|
||||
.f-input{height:20px;border:1px solid #D4D0CA;border-radius:2px;background:#fff;font-size:7.5px;padding:0 7px;color:#333;display:flex;align-items:center}
|
||||
.f-input.focus{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.12)}
|
||||
.f-input.filled{color:#002850;font-weight:600;background:#FAFBFF}
|
||||
.f-input.suggested{border-color:#A6DAD8;background:#F0FAFA;color:#005858;font-weight:600}
|
||||
.f-input.empty{color:#BBB;font-style:italic}
|
||||
.f-input.tall{height:28px}
|
||||
|
||||
.f-tags{display:flex;gap:3px;flex-wrap:wrap;min-height:20px;border:1px solid #D4D0CA;border-radius:2px;padding:2px 4px;background:#fff;align-items:center}
|
||||
.f-chip{background:#002850;color:#A6DAD8;border-radius:2px;font-size:6px;font-weight:700;padding:1px 4px 1px 5px;display:flex;align-items:center;gap:2px}
|
||||
.f-chip-rm{color:rgba(166,218,216,.5);font-weight:400}
|
||||
|
||||
/* ── Action bar ── */
|
||||
.action-bar{height:46px;background:#F5F4EE;border-top:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px;flex-shrink:0}
|
||||
.btn-skip{font-size:7px;font-weight:700;color:#AAA;letter-spacing:.2px;cursor:pointer}
|
||||
.btn-spacer{flex:1}
|
||||
.btn-outline{height:24px;padding:0 12px;border:1px solid #C0BDB6;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#777;display:flex;align-items:center;cursor:pointer;background:#fff}
|
||||
.btn-primary{height:24px;padding:0 12px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;background:#002850;color:#fff;display:flex;align-items:center;cursor:pointer;gap:4px}
|
||||
.btn-primary.green{background:#1A7040}
|
||||
|
||||
/* ─────────────────────────────────────── */
|
||||
/* ── CONCEPT A — Stack (mobile-first) ── */
|
||||
/* ─────────────────────────────────────── */
|
||||
.ca-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:8px}
|
||||
.ca-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
|
||||
.ca-title{flex:1;text-align:center;font-family:'Merriweather',Georgia,serif;font-size:9px;color:#002850;font-weight:600}
|
||||
.ca-count{font-size:7px;font-weight:700;color:#002850;background:#A6DAD8;padding:2px 6px;border-radius:10px;letter-spacing:.3px}
|
||||
|
||||
.ca-body{background:#ECEAE4;padding:14px 12px;overflow-y:auto}
|
||||
|
||||
.ca-drop{background:#fff;border:2px dashed #A6DAD8;border-radius:4px;padding:14px;text-align:center;margin-bottom:14px}
|
||||
.ca-drop-icon{font-size:18px;color:#A6DAD8;margin-bottom:4px}
|
||||
.ca-drop-title{font-size:8.5px;font-weight:700;color:#002850;margin-bottom:2px}
|
||||
.ca-drop-sub{font-size:6.5px;color:#999}
|
||||
|
||||
.ca-shared-card{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;margin-bottom:14px;box-shadow:0 1px 2px rgba(0,0,0,.03)}
|
||||
.ca-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:11px}
|
||||
.ca-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.ca-shared-title{font-family:'Merriweather',Georgia,serif;font-size:9.5px;color:#002850;font-weight:700}
|
||||
.ca-shared-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 10px}
|
||||
.ca-shared-grid .full{grid-column:1/-1}
|
||||
.ca-shared-field{display:flex;flex-direction:column;gap:3px}
|
||||
|
||||
.ca-files-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 2px}
|
||||
.ca-files-title{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6}
|
||||
.ca-files-add{font-size:7px;font-weight:700;color:#002850;display:flex;align-items:center;gap:3px}
|
||||
|
||||
.ca-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:9px 10px;margin-bottom:7px;display:flex;align-items:center;gap:10px}
|
||||
.ca-file.active{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.08)}
|
||||
.ca-thumb{width:28px;height:36px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;flex-shrink:0;display:flex;flex-direction:column;padding:3px;gap:1px}
|
||||
.ca-thumb .tl{height:2px;background:#C4BDB0;opacity:.6;border-radius:1px}
|
||||
.ca-thumb .tl.s{width:60%;opacity:.35}
|
||||
.ca-thumb .tl.m{width:82%}
|
||||
.ca-file-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
||||
.ca-file-title{font-size:8px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ca-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
|
||||
.ca-file-meta{font-size:6.5px;color:#AAA}
|
||||
.ca-file-rm{font-size:10px;color:#B0ADA6;padding:0 4px;cursor:pointer}
|
||||
|
||||
/* ───────────────────────────────────────────── */
|
||||
/* ── CONCEPT B — Split-panel + file switcher ── */
|
||||
/* ───────────────────────────────────────────── */
|
||||
.cb-top-bar{height:38px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:10px}
|
||||
.cb-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
|
||||
.cb-title{font-family:'Merriweather',Georgia,serif;font-size:9px;font-weight:700;color:#002850}
|
||||
.cb-count{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:10px;font-size:7px;font-weight:800;letter-spacing:.3px}
|
||||
.cb-discard{margin-left:auto;font-size:7px;font-weight:700;color:#C0392B;letter-spacing:.2px}
|
||||
|
||||
.cb-split{display:flex;min-height:440px}
|
||||
.cb-pdf{flex:55;background:#5E5C59;display:flex;flex-direction:column;border-right:1px solid #3A3836}
|
||||
.cb-pdf-toolbar{height:28px;background:#3A3836;display:flex;align-items:center;padding:0 10px;gap:8px}
|
||||
.cb-pdf-btn{width:16px;height:16px;border-radius:2px;background:rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center;font-size:7px;color:rgba(255,255,255,.6)}
|
||||
.cb-pdf-page{font-size:6.5px;color:rgba(255,255,255,.4);margin-left:auto;font-weight:700;letter-spacing:.5px}
|
||||
.cb-pdf-view{flex:1;display:flex;justify-content:center;padding:14px;overflow:hidden}
|
||||
.cb-paper{background:#FFFEF8;box-shadow:0 2px 10px rgba(0,0,0,.3);border-radius:1px;padding:14px 16px;display:flex;flex-direction:column;gap:0;width:180px;flex-shrink:0}
|
||||
.pl{height:4px;background:#C4BDB0;border-radius:1px;opacity:.55;margin-bottom:3px}
|
||||
.pl.h{height:6px;opacity:.75;margin-bottom:5px}
|
||||
.pl.s{width:55%;opacity:.3}
|
||||
.pl.m{width:80%}
|
||||
.pl.sp{height:7px;background:transparent}
|
||||
.cb-filebar{background:#434140;border-top:1px solid #3A3836;display:flex;align-items:center;padding:0 8px;gap:3px;height:36px;flex-shrink:0}
|
||||
.cb-fb-arrow{width:18px;height:22px;border-radius:2px;background:rgba(255,255,255,.08);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,.6)}
|
||||
.cb-fb-track{flex:1;display:flex;gap:3px;padding:0 3px;overflow:hidden}
|
||||
.cb-fb-item{padding:3px 6px;border-radius:2px;font-size:6px;font-weight:700;color:rgba(255,255,255,.55);background:rgba(255,255,255,.06);display:flex;align-items:center;gap:4px;white-space:nowrap}
|
||||
.cb-fb-item.on{background:#A6DAD8;color:#002850}
|
||||
.cb-fb-num{background:rgba(0,0,0,.15);border-radius:2px;padding:0 3px;font-size:5.5px;font-weight:800}
|
||||
.cb-fb-item.on .cb-fb-num{background:rgba(0,40,80,.25);color:#002850}
|
||||
|
||||
.cb-form{flex:45;background:#fff;display:flex;flex-direction:column}
|
||||
.cb-form-scroll{flex:1;overflow-y:auto;padding:14px}
|
||||
|
||||
.cb-only-card{background:#F0FAFA;border:1px solid #A6DAD8;border-radius:3px;padding:10px 12px;margin-bottom:12px}
|
||||
.cb-only-head{display:flex;align-items:center;gap:6px;margin-bottom:7px}
|
||||
.cb-only-badge{background:#005858;color:#A6DAD8;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.cb-only-subtitle{font-size:6.5px;color:#005858;font-weight:600;letter-spacing:.3px}
|
||||
|
||||
.cb-shared-card{background:#F9F8F5;border:1px solid #E4E2D7;border-radius:3px;padding:10px 12px;margin-bottom:10px}
|
||||
.cb-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:9px}
|
||||
.cb-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.cb-shared-subtitle{font-size:6.5px;color:#002850;font-weight:600}
|
||||
.cb-row{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-bottom:7px}
|
||||
.cb-row.full{grid-template-columns:1fr}
|
||||
.cb-field{display:flex;flex-direction:column;gap:3px}
|
||||
|
||||
/* ─────────────────────────────────────── */
|
||||
/* ── CONCEPT C — Progressive accordion ── */
|
||||
/* ─────────────────────────────────────── */
|
||||
.cc-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px}
|
||||
|
||||
.cc-body{background:#ECEAE4;padding:14px;display:flex;flex-direction:column;gap:11px;max-height:540px;overflow-y:auto}
|
||||
|
||||
.cc-shared{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;box-shadow:0 1px 2px rgba(0,0,0,.03);position:sticky;top:0;z-index:2}
|
||||
.cc-shared-head{display:flex;align-items:center;gap:7px;margin-bottom:11px}
|
||||
.cc-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
|
||||
.cc-shared-title{font-family:'Merriweather',Georgia,serif;font-size:10px;color:#002850;font-weight:700}
|
||||
.cc-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px 10px}
|
||||
.cc-grid .span2{grid-column:span 2}
|
||||
|
||||
.cc-files-label{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6;padding:0 2px;margin-top:6px}
|
||||
|
||||
.cc-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;overflow:hidden}
|
||||
.cc-file.open{border-color:#002850;box-shadow:0 2px 6px rgba(0,40,80,.08)}
|
||||
.cc-file-head{display:flex;align-items:center;gap:10px;padding:9px 12px;cursor:pointer}
|
||||
.cc-file-head.open{border-bottom:1px solid #E4E2D7;background:#F9F8F5}
|
||||
.cc-caret{font-size:9px;color:#A6DAD8;width:10px}
|
||||
.cc-file-thumb{width:22px;height:28px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;padding:2px;display:flex;flex-direction:column;gap:1px;flex-shrink:0}
|
||||
.cc-file-thumb .tl{height:2px;background:#C4BDB0;opacity:.55;border-radius:1px}
|
||||
.cc-file-body{flex:1;min-width:0}
|
||||
.cc-file-titlerow{display:flex;align-items:center;gap:7px}
|
||||
.cc-file-title{font-size:8.5px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cc-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
|
||||
.cc-file-meta{font-size:6.5px;color:#AAA;margin-top:2px}
|
||||
.cc-file-rm{font-size:11px;color:#B0ADA6;padding:0 4px}
|
||||
|
||||
.cc-file-open{display:flex;background:#F5F4EE}
|
||||
.cc-preview{flex:45;background:#5E5C59;padding:12px;display:flex;justify-content:center}
|
||||
.cc-preview-paper{background:#FFFEF8;border-radius:1px;padding:8px 10px;width:110px;flex-shrink:0;display:flex;flex-direction:column;box-shadow:0 2px 6px rgba(0,0,0,.25)}
|
||||
.cc-file-form{flex:55;padding:12px 14px;background:#fff;display:flex;flex-direction:column;gap:7px}
|
||||
|
||||
/* ─────────── Decision matrix ─────────── */
|
||||
.decision{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;margin:88px 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.decision h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:6px}
|
||||
.decision p.lead{font-size:13.5px;color:#555;line-height:1.7;margin-bottom:22px;max-width:820px}
|
||||
.dm{width:100%;border-collapse:collapse;margin-top:12px;font-size:12px}
|
||||
.dm th{text-align:left;font-size:9.5px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:#002850;padding:9px 12px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
|
||||
.dm td{padding:13px 12px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.6}
|
||||
.dm td:first-child{font-weight:700;color:#002850;width:18%;white-space:nowrap}
|
||||
.dm td.score{font-size:15px;text-align:center;width:12%}
|
||||
.dm td.ok{color:#1A7040}
|
||||
.dm td.mid{color:#A07100}
|
||||
.dm td.bad{color:#C0392B}
|
||||
|
||||
/* ─────────── Recommendation ─────────── */
|
||||
.reco{background:#002850;color:#fff;border-radius:6px;padding:36px 40px;margin:48px 0 64px;box-shadow:0 4px 20px rgba(0,40,80,.15)}
|
||||
.reco .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
|
||||
.reco h2{font-family:'Merriweather',Georgia,serif;font-size:26px;font-weight:700;margin-top:6px}
|
||||
.reco .why{font-size:13.5px;line-height:1.85;color:rgba(255,255,255,.88);max-width:780px;margin-top:14px}
|
||||
.reco ul{list-style:none;margin-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:9px 26px}
|
||||
.reco ul li{font-size:12.5px;color:rgba(255,255,255,.9);padding-left:22px;position:relative;line-height:1.6}
|
||||
.reco ul li::before{content:"✓";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||
|
||||
/* ─────────── Impl-ref ─────────── */
|
||||
.impl{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.impl h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:16px}
|
||||
.impl h3{font-family:'Merriweather',Georgia,serif;font-size:15px;color:#002850;margin:22px 0 10px}
|
||||
.impl-table{width:100%;border-collapse:collapse;margin-top:6px;font-size:12px}
|
||||
.impl-table th{text-align:left;font-size:9px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:#002850;padding:8px 10px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
|
||||
.impl-table td{padding:10px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.55}
|
||||
.impl-table td:first-child{font-weight:700;color:#002850;width:22%}
|
||||
.impl-table td code{font-family:'SF Mono','Menlo',monospace;font-size:11px;background:#F0EEE8;padding:1px 6px;border-radius:2px;color:#002850}
|
||||
.impl-table td.px{color:#777;font-size:11.5px;width:16%}
|
||||
.impl-table td.note{color:#888;font-size:11.5px;font-style:italic;width:22%}
|
||||
.impl h3.ix{margin-top:32px}
|
||||
|
||||
.notes{background:#F9F8F5;border-left:3px solid #A6DAD8;padding:16px 22px;border-radius:0 4px 4px 0;margin-top:26px}
|
||||
.notes .nh{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#002850;margin-bottom:8px}
|
||||
.notes ul{list-style:none;display:flex;flex-direction:column;gap:6px}
|
||||
.notes li{font-size:12px;color:#333;padding-left:18px;position:relative;line-height:1.7}
|
||||
.notes li::before{content:"•";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ═══════════════ MASTHEAD ══════════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="mh">
|
||||
<div class="kicker">UX Spec · Bulk Upload</div>
|
||||
<h1>Uploading multiple documents in a single pass</h1>
|
||||
<p>
|
||||
Extends issue <strong>#294</strong> (new-document split-panel) with bulk uploads. When a user drops
|
||||
N files, every metadata field applies once to all of them — only the <em>title</em> is per-file,
|
||||
pre-filled from the filename and editable inline. A single save POST creates N documents.
|
||||
</p>
|
||||
<div class="byline">Prepared by Leonie Voss · 2026-04-24 · Draft 1 · References: #294, #305</div>
|
||||
<div class="tag-row">
|
||||
<span class="tag">feature</span>
|
||||
<span class="tag mint">ui</span>
|
||||
<span class="tag gray">a11y 320px+</span>
|
||||
<span class="tag green">backend ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goals -->
|
||||
<div class="goals">
|
||||
<h2>Design goals</h2>
|
||||
<ul>
|
||||
<li><strong>One-pass feel</strong>: drop → fill shared fields → save. No wizard, no per-file detour.</li>
|
||||
<li><strong>Every field is shared except the title</strong>, which is always set (filename-derived).</li>
|
||||
<li><strong>No mode switch</strong>: 1 file and N files use the same screen — more files reveal more chrome.</li>
|
||||
<li><strong>Scales to 20+ files</strong> without the form losing scan-ability on mobile.</li>
|
||||
<li><strong>Reuses the #294 split-panel layout</strong> (DocumentEditLayout) — minimum new surface.</li>
|
||||
<li><strong>a11y-first</strong>: 44px targets, focus states, `aria-current` on active file, keyboard-navigable.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ═════════ CONCEPT A — STACK ═════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<section class="concept">
|
||||
<div class="concept-header">
|
||||
<div class="concept-num">A</div>
|
||||
<div>
|
||||
<div class="concept-label">Concept A</div>
|
||||
<div class="concept-title">Flat Stack — shared header · file cards · sticky save</div>
|
||||
<p class="concept-desc">
|
||||
A single vertical flow: drop zone on top, then a <em>Gilt für alle</em> metadata card,
|
||||
then stacked file cards (thumbnail · editable title · remove). No split panel, no tabs.
|
||||
Scrolling down reveals all files; the save bar sticks to the bottom.
|
||||
</p>
|
||||
<div class="concept-best">
|
||||
<span class="best-label">Best for</span>
|
||||
<span class="best-text">Small-screen workflows. Seniors who prefer linear flows over tabs.</span>
|
||||
</div>
|
||||
<div class="concept-tradeoff">
|
||||
Trade-off: no PDF preview until you click through to the document after save. Harder to verify
|
||||
you grabbed the right files before committing.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- mobile mockup -->
|
||||
<div class="screen narrow">
|
||||
<div class="chrome">
|
||||
<div class="chrome-bar">
|
||||
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
|
||||
<div class="chrome-url"></div>
|
||||
<div class="viewport-hint">375 · mobile</div>
|
||||
</div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">Familienarchiv</div>
|
||||
<div class="app-nav-r">
|
||||
<div class="app-avatar">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ca-top-bar">
|
||||
<div class="ca-back">← Zurück</div>
|
||||
<div class="ca-title">Neue Dokumente</div>
|
||||
<div class="ca-count">5</div>
|
||||
</div>
|
||||
<div class="ca-body" style="height:500px">
|
||||
<!-- drop zone -->
|
||||
<div class="ca-drop">
|
||||
<div class="ca-drop-icon">⇪</div>
|
||||
<div class="ca-drop-title">Weitere Dateien hinzufügen</div>
|
||||
<div class="ca-drop-sub">PDF, JPEG, PNG, TIFF · max 50 MB</div>
|
||||
</div>
|
||||
|
||||
<!-- shared card -->
|
||||
<div class="ca-shared-card">
|
||||
<div class="ca-shared-head">
|
||||
<span class="ca-shared-badge">Gilt für alle 5</span>
|
||||
<span class="ca-shared-title">Angaben</span>
|
||||
</div>
|
||||
<div class="ca-shared-grid">
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Absender</span>
|
||||
<div class="f-input filled">Hans Müller</div>
|
||||
</div>
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Empfänger</span>
|
||||
<div class="f-input filled">Anna Schmidt</div>
|
||||
</div>
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Datum</span>
|
||||
<div class="f-input filled">1950-06</div>
|
||||
</div>
|
||||
<div class="ca-shared-field">
|
||||
<span class="f-label">Ort</span>
|
||||
<div class="f-input empty">Berlin</div>
|
||||
</div>
|
||||
<div class="ca-shared-field full">
|
||||
<span class="f-label">Tags</span>
|
||||
<div class="f-tags">
|
||||
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
|
||||
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- files list -->
|
||||
<div class="ca-files-head">
|
||||
<div class="ca-files-title">5 Dateien · Titel bearbeiten</div>
|
||||
</div>
|
||||
<div class="ca-file active">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl m"></div><div class="tl s"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title">Brief_1940_Hans</div>
|
||||
<div class="ca-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="ca-file">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl"></div><div class="tl s"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title">Brief_1940_Anna</div>
|
||||
<div class="ca-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="ca-file">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl m"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title">Brief_1941_Clara</div>
|
||||
<div class="ca-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="ca-file">
|
||||
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl s"></div><div class="tl m"></div></div>
|
||||
<div class="ca-file-body">
|
||||
<div class="ca-file-title placeholder">Postkarte_Venedig</div>
|
||||
<div class="ca-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
|
||||
</div>
|
||||
<div class="ca-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<div class="btn-skip">Alle verwerfen</div>
|
||||
<div class="btn-spacer"></div>
|
||||
<div class="btn-outline">Als Platzhalter</div>
|
||||
<div class="btn-primary">5 speichern →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ═══ CONCEPT B — SPLIT-PANEL + SWITCHER ══ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<section class="concept">
|
||||
<div class="concept-header">
|
||||
<div class="concept-num">B</div>
|
||||
<div>
|
||||
<div class="concept-label">Concept B · RECOMMENDED</div>
|
||||
<div class="concept-title">Split-Panel with File Switcher</div>
|
||||
<p class="concept-desc">
|
||||
Reuses the <em>DocumentEditLayout</em> from issue #294 and adds a horizontal file-switcher strip
|
||||
under the PDF preview. Right column splits into two cards: <em>Gilt nur für diese Datei</em>
|
||||
(title only, mint accent) and <em>Gilt für alle N Dokumente</em> (everything else).
|
||||
When N=1 the switcher disappears and the screen is byte-identical to #294.
|
||||
</p>
|
||||
<div class="concept-best">
|
||||
<span class="best-label">Best for</span>
|
||||
<span class="best-text">The project's primary use case. Desktop + tablet, matches #294 DNA.</span>
|
||||
</div>
|
||||
<div class="concept-tradeoff">
|
||||
Trade-off: on mobile the split has to collapse into tabs ("Vorschau / Angaben"). We reuse the
|
||||
same responsive pattern that DocumentEditLayout already ships with.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- desktop mockup -->
|
||||
<div class="screen">
|
||||
<div class="chrome">
|
||||
<div class="chrome-bar">
|
||||
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
|
||||
<div class="chrome-url"></div>
|
||||
<div class="viewport-hint">1280 · desktop</div>
|
||||
</div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">Familienarchiv</div>
|
||||
<div class="app-link on">Dokumente</div>
|
||||
<div class="app-link">Personen</div>
|
||||
<div class="app-link">Briefwechsel</div>
|
||||
<div class="app-link">Chronik</div>
|
||||
<div class="app-nav-r">
|
||||
<div class="app-avatar">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-top-bar">
|
||||
<div class="cb-back">← Dokumente</div>
|
||||
<div class="cb-title">Neue Dokumente</div>
|
||||
<div class="cb-count">5 werden erstellt</div>
|
||||
<div class="cb-discard">Alle verwerfen</div>
|
||||
</div>
|
||||
|
||||
<div class="cb-split">
|
||||
<!-- PDF side -->
|
||||
<div class="cb-pdf">
|
||||
<div class="cb-pdf-toolbar">
|
||||
<div class="cb-pdf-btn">◀</div>
|
||||
<div class="cb-pdf-btn">▶</div>
|
||||
<div class="cb-pdf-btn">+</div>
|
||||
<div class="cb-pdf-btn">−</div>
|
||||
<div class="cb-pdf-page">Seite 1 / 2 · Datei 1 von 5</div>
|
||||
</div>
|
||||
<div class="cb-pdf-view">
|
||||
<div class="cb-paper">
|
||||
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
|
||||
<div class="pl s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
|
||||
<div class="pl"></div><div class="pl s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- file switcher -->
|
||||
<div class="cb-filebar">
|
||||
<div class="cb-fb-arrow">‹</div>
|
||||
<div class="cb-fb-track">
|
||||
<div class="cb-fb-item on"><span class="cb-fb-num">1</span> Brief_1940_Hans.pdf</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">2</span> Brief_1940_Anna.pdf</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">3</span> Brief_1941_Clara.pdf</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">4</span> Postkarte_Venedig.jpg</div>
|
||||
<div class="cb-fb-item"><span class="cb-fb-num">5</span> Urkunde_1942.pdf</div>
|
||||
</div>
|
||||
<div class="cb-fb-arrow">›</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form side -->
|
||||
<div class="cb-form">
|
||||
<div class="cb-form-scroll">
|
||||
<!-- PER-FILE card -->
|
||||
<div class="cb-only-card">
|
||||
<div class="cb-only-head">
|
||||
<span class="cb-only-badge">Nur diese Datei</span>
|
||||
<span class="cb-only-subtitle">1 / 5 · Brief_1940_Hans.pdf</span>
|
||||
</div>
|
||||
<div class="cb-row full">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Titel <span class="f-req">*</span></span>
|
||||
<div class="f-input filled tall">Brief an Anna, 1940</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SHARED card -->
|
||||
<div class="cb-shared-card">
|
||||
<div class="cb-shared-head">
|
||||
<span class="cb-shared-badge">Gilt für alle 5</span>
|
||||
<span class="cb-shared-subtitle">Gemeinsame Angaben</span>
|
||||
</div>
|
||||
<div class="cb-row">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Absender</span>
|
||||
<div class="f-input filled">Hans Müller</div>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Empfänger</span>
|
||||
<div class="f-input filled">Anna Schmidt</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-row">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Datum</span>
|
||||
<div class="f-input filled">15.06.1950</div>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Ort</span>
|
||||
<div class="f-input empty">z.B. Berlin</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-row full">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Tags</span>
|
||||
<div class="f-tags">
|
||||
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
|
||||
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
|
||||
<span class="f-chip">Briefwechsel <span class="f-chip-rm">×</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cb-row">
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Archivbox</span>
|
||||
<div class="f-input empty">z.B. B-12</div>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Mappe</span>
|
||||
<div class="f-input empty">z.B. M-3</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<div class="btn-skip">Alle verwerfen</div>
|
||||
<div class="btn-spacer"></div>
|
||||
<div class="btn-outline">Als Platzhalter</div>
|
||||
<div class="btn-primary green">5 speichern →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══ CONCEPT C — PROGRESSIVE ACCORDION ══ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<section class="concept">
|
||||
<div class="concept-header">
|
||||
<div class="concept-num">C</div>
|
||||
<div>
|
||||
<div class="concept-label">Concept C</div>
|
||||
<div class="concept-title">Progressive Accordion — shared sticky header · file cards expand inline</div>
|
||||
<p class="concept-desc">
|
||||
Shared metadata sticks at the top of the page. Below, each file is a collapsed card; clicking
|
||||
a card expands it to show the PDF preview + title field inline. Only one card is expanded at a
|
||||
time. Scales well to 20+ files — the list stays readable, you only look at the PDFs you want
|
||||
to verify.
|
||||
</p>
|
||||
<div class="concept-best">
|
||||
<span class="best-label">Best for</span>
|
||||
<span class="best-text">Large batches (10+ files) where you want to spot-check a few.</span>
|
||||
</div>
|
||||
<div class="concept-tradeoff">
|
||||
Trade-off: two different visual languages — cards collapsed vs. cards expanded with PDF. New
|
||||
pattern for the project; costs familiarity.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="screen">
|
||||
<div class="chrome">
|
||||
<div class="chrome-bar">
|
||||
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
|
||||
<div class="chrome-url"></div>
|
||||
<div class="viewport-hint">1280 · desktop</div>
|
||||
</div>
|
||||
<div class="app-nav">
|
||||
<div class="app-logo">Familienarchiv</div>
|
||||
<div class="app-link on">Dokumente</div>
|
||||
<div class="app-link">Personen</div>
|
||||
<div class="app-nav-r"><div class="app-avatar">MR</div></div>
|
||||
</div>
|
||||
<div class="cc-top-bar">
|
||||
<div class="ca-back">← Zurück</div>
|
||||
<div class="ca-title">Neue Dokumente</div>
|
||||
<div class="ca-count">5</div>
|
||||
</div>
|
||||
|
||||
<div class="cc-body">
|
||||
<!-- sticky shared card -->
|
||||
<div class="cc-shared">
|
||||
<div class="cc-shared-head">
|
||||
<span class="cc-shared-badge">Gilt für alle 5</span>
|
||||
<span class="cc-shared-title">Gemeinsame Angaben</span>
|
||||
</div>
|
||||
<div class="cc-grid">
|
||||
<div class="cb-field"><span class="f-label">Absender</span><div class="f-input filled">Hans Müller</div></div>
|
||||
<div class="cb-field"><span class="f-label">Empfänger</span><div class="f-input filled">Anna Schmidt</div></div>
|
||||
<div class="cb-field"><span class="f-label">Datum</span><div class="f-input filled">15.06.1950</div></div>
|
||||
<div class="cb-field span2"><span class="f-label">Tags</span><div class="f-tags"><span class="f-chip">Familie <span class="f-chip-rm">×</span></span><span class="f-chip">Krieg <span class="f-chip-rm">×</span></span></div></div>
|
||||
<div class="cb-field"><span class="f-label">Ort</span><div class="f-input empty">z.B. Berlin</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cc-files-label">5 Dateien</div>
|
||||
|
||||
<!-- collapsed card -->
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title">Brief an Anna, 1940</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- expanded card -->
|
||||
<div class="cc-file open">
|
||||
<div class="cc-file-head open">
|
||||
<div class="cc-caret" style="color:#002850">▾</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title">Brief von Anna, Antwort</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
<div class="cc-file-open">
|
||||
<div class="cc-preview">
|
||||
<div class="cc-preview-paper">
|
||||
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
|
||||
<div class="pl sp"></div>
|
||||
<div class="pl"></div><div class="pl"></div><div class="pl m"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-file-form">
|
||||
<div class="cb-only-head">
|
||||
<span class="cb-only-badge">Nur diese Datei</span>
|
||||
<span class="cb-only-subtitle">2 / 5</span>
|
||||
</div>
|
||||
<div class="cb-field">
|
||||
<span class="f-label">Titel <span class="f-req">*</span></span>
|
||||
<div class="f-input filled tall">Brief von Anna, Antwort</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- more collapsed -->
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title placeholder">Brief_1941_Clara</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title placeholder">Postkarte_Venedig</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cc-file">
|
||||
<div class="cc-file-head">
|
||||
<div class="cc-caret">▸</div>
|
||||
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
|
||||
<div class="cc-file-body">
|
||||
<div class="cc-file-titlerow">
|
||||
<div class="cc-file-title placeholder">Urkunde_1942</div>
|
||||
</div>
|
||||
<div class="cc-file-meta">Urkunde_1942.pdf · 3.1 MB</div>
|
||||
</div>
|
||||
<div class="cc-file-rm">✕</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<div class="btn-skip">Alle verwerfen</div>
|
||||
<div class="btn-spacer"></div>
|
||||
<div class="btn-outline">Als Platzhalter</div>
|
||||
<div class="btn-primary green">5 speichern →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══════════ DECISION MATRIX ════════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="decision">
|
||||
<h2>Decision matrix</h2>
|
||||
<p class="lead">
|
||||
All three concepts meet the core requirement (shared metadata + per-file title + one save).
|
||||
Graded against what matters for the senior audience, the responsive constraint, and the #294
|
||||
architectural commitment.
|
||||
</p>
|
||||
<table class="dm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dimension</th>
|
||||
<th>A · Stack</th>
|
||||
<th>B · Split-Panel</th>
|
||||
<th>C · Accordion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Reuses #294 layout</td>
|
||||
<td class="score bad">✕</td>
|
||||
<td class="score ok">✓</td>
|
||||
<td class="score bad">✕</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Single-file mode unchanged</td>
|
||||
<td class="score mid">rewrite</td>
|
||||
<td class="score ok">identical</td>
|
||||
<td class="score bad">different</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>PDF visible before save</td>
|
||||
<td class="score bad">no</td>
|
||||
<td class="score ok">always</td>
|
||||
<td class="score mid">one at a time</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Works at 320px</td>
|
||||
<td class="score ok">native</td>
|
||||
<td class="score mid">via tab collapse</td>
|
||||
<td class="score ok">native</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scales to 20 files</td>
|
||||
<td class="score mid">long scroll</td>
|
||||
<td class="score ok">switcher scrolls</td>
|
||||
<td class="score ok">collapsed list</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>New Svelte components</td>
|
||||
<td class="score bad">3 new</td>
|
||||
<td class="score ok">1 new (switcher)</td>
|
||||
<td class="score bad">4 new</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Familiar pattern</td>
|
||||
<td class="score ok">yes</td>
|
||||
<td class="score ok">yes (post-#294)</td>
|
||||
<td class="score mid">new to app</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══════════ RECOMMENDATION ════════════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="reco">
|
||||
<div class="kicker">Recommendation</div>
|
||||
<h2>Ship Concept B</h2>
|
||||
<p class="why">
|
||||
Concept B treats bulk upload as a <em>polymorphic state</em> of the existing single-document
|
||||
layout rather than a separate screen. A user who drops one file gets exactly the #294 experience.
|
||||
A user who drops five gets the same screen plus a horizontal file-switcher and a two-card split
|
||||
(<em>Nur diese Datei</em> vs. <em>Gilt für alle</em>). Nothing about the single-file flow changes.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Keeps the mental model: "one form, one save" regardless of file count.</li>
|
||||
<li>PDF preview is persistent — you can spot-check each scan before committing.</li>
|
||||
<li>The per-file title is visually promoted with a mint border so it reads as the one thing that differs per file.</li>
|
||||
<li>Reuses DocumentEditLayout: the delta is ~1 new component (<code>FileSwitcherStrip</code>) + two cards in the form.</li>
|
||||
<li>Single-file mode is byte-identical to #294 — no regression risk for existing users.</li>
|
||||
<li>Backend is already ready (<code>POST /api/documents/quick-upload</code> accepts N files in one multipart).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<!-- ══════════ IMPL-REF · CONCEPT B ═══════ -->
|
||||
<!-- ════════════════════════════════════════════ -->
|
||||
<div class="impl">
|
||||
<h2>Implementation reference — Concept B</h2>
|
||||
|
||||
<h3>Top bar (when N > 1)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Count pill "N werden erstellt"</td>
|
||||
<td><code>bg-accent text-primary rounded-full px-3 py-1 text-sm font-bold</code></td>
|
||||
<td class="px">14px · 700</td>
|
||||
<td class="note">brand-mint on brand-navy</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>"Alle verwerfen" link</td>
|
||||
<td><code>ml-auto text-sm font-bold text-red-600 hover:text-red-800 focus-visible:outline-2 focus-visible:outline-red-600</code></td>
|
||||
<td class="px">14px / 44px target</td>
|
||||
<td class="note">confirm dialog before wiping</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">FileSwitcherStrip (new component)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Strip container</td>
|
||||
<td><code>flex items-center gap-1 bg-ink/95 px-2 py-2 border-t border-ink/80</code></td>
|
||||
<td class="px">height 48px</td>
|
||||
<td class="note">under the PDF toolbar, on the dark panel</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Arrow buttons</td>
|
||||
<td><code>h-10 w-10 rounded-sm bg-white/8 text-surface/60 hover:bg-white/15 focus-visible:outline-2</code></td>
|
||||
<td class="px">40×40 (44 w/padding)</td>
|
||||
<td class="note"><code>aria-label="Vorherige Datei"</code> / "Nächste Datei"</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File chip (inactive)</td>
|
||||
<td><code>px-3 py-2 rounded-sm bg-white/6 text-sm font-bold text-surface/55 whitespace-nowrap hover:bg-white/12</code></td>
|
||||
<td class="px">14px / h 40px</td>
|
||||
<td class="note">horizontal scroll container uses <code>snap-x snap-mandatory</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File chip (active)</td>
|
||||
<td><code>... bg-accent text-primary</code> + <code>aria-current="true"</code></td>
|
||||
<td class="px">14px / h 40px</td>
|
||||
<td class="note">mint pill, primary text — 7.2:1 contrast passes AAA</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Chip number prefix</td>
|
||||
<td><code>bg-primary/25 rounded-sm px-1 mr-2 text-xs font-extrabold</code></td>
|
||||
<td class="px">12px / 800</td>
|
||||
<td class="note">"1", "2", … — for quick scanning</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">"Nur diese Datei" card (per-file scope)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Card container</td>
|
||||
<td><code>bg-accent/20 border border-accent rounded-sm p-4 mb-4</code></td>
|
||||
<td class="px">padding 16px</td>
|
||||
<td class="note">mint tint signals "different per file"</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scope badge</td>
|
||||
<td><code>bg-primary/90 text-accent rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
|
||||
<td class="px">12px · 800</td>
|
||||
<td class="note">Paraglide key: <code>bulk_only_this_file</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Title input</td>
|
||||
<td><code>h-11 text-base font-semibold text-ink bg-white border border-line rounded-sm px-3 focus-visible:border-ink focus-visible:ring-2 focus-visible:ring-ink/20</code></td>
|
||||
<td class="px">44px min-height · 16px</td>
|
||||
<td class="note">pre-filled from filename <em>without extension</em></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">"Gilt für alle" card (shared scope)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Card container</td>
|
||||
<td><code>bg-surface border border-line rounded-sm p-4 mb-3</code></td>
|
||||
<td class="px">padding 16px</td>
|
||||
<td class="note">neutral (no accent tint)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scope badge</td>
|
||||
<td><code>bg-accent text-primary rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
|
||||
<td class="px">12px · 800</td>
|
||||
<td class="note">Paraglide: <code>bulk_shared_count</code> ("Gilt für alle {count}")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Field grid</td>
|
||||
<td><code>grid grid-cols-1 md:grid-cols-2 gap-3</code></td>
|
||||
<td class="px">12px gap</td>
|
||||
<td class="note">single column at 320px, two at ≥ 768px</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">Save bar</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Primary save button</td>
|
||||
<td><code>h-11 px-5 bg-green-700 hover:bg-green-800 text-white font-extrabold rounded-sm text-sm focus-visible:ring-2 focus-visible:ring-green-900</code></td>
|
||||
<td class="px">44px min · 14px</td>
|
||||
<td class="note">label <code>{count} speichern →</code> (plural-aware Paraglide)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>"Als Platzhalter" (outline)</td>
|
||||
<td><code>h-11 px-4 border border-line bg-white text-ink-3 font-bold rounded-sm text-sm</code></td>
|
||||
<td class="px">44px</td>
|
||||
<td class="note">posts with <code>metadataComplete=false</code> for all</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 class="ix">Responsive collapse (≤ 767px)</h3>
|
||||
<table class="impl-table">
|
||||
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
|
||||
<tr>
|
||||
<td>Panel mode switch</td>
|
||||
<td>reuses DocumentEditLayout's existing tab collapse — "Vorschau / Angaben" tabs</td>
|
||||
<td class="px">tab height 48px</td>
|
||||
<td class="note">already shipped with #294</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File switcher stays on "Vorschau" tab</td>
|
||||
<td><code>snap-x snap-mandatory overflow-x-auto</code></td>
|
||||
<td class="px">h 44px</td>
|
||||
<td class="note">horizontal swipe; arrow buttons removed at mobile</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="notes">
|
||||
<div class="nh">Interactions + behaviour</div>
|
||||
<ul>
|
||||
<li><strong>Drop a file after the initial batch</strong>: append to the end of the list and switch focus to the newly added file. No modal, no confirmation.</li>
|
||||
<li><strong>Remove a file</strong> (X on the chip) → confirm only if it's the currently-previewed one; otherwise silent. When count drops to 1 the switcher strip animates away (200ms); when it drops to 0 we redirect back to the drop-zone state.</li>
|
||||
<li><strong>Title auto-fill</strong>: <code>filename.replace(/\.(pdf|jpe?g|png|tiff?)$/i, '').replace(/[_-]+/g, ' ').trim()</code>. Marks the title input as <code>suggested</code> until the user edits it (mint left border, same treatment as #294's filename-derived fields).</li>
|
||||
<li><strong>Title field visibility</strong>: always rendered (never collapsed) even in single-file mode, so there's zero layout jump when N changes from 1 to 2.</li>
|
||||
<li><strong>Save flow</strong>: single POST to <code>/api/documents/quick-upload</code> with N files + JSON metadata object containing shared fields + titles array. Backend maps title[i] to files[i] by index. Response splits into <code>created[] / updated[] / errors[]</code> — show a summary toast + inline error markers per file for the <code>errors[]</code> list.</li>
|
||||
<li><strong>Keyboard navigation</strong>: <kbd>←</kbd>/<kbd>→</kbd> on the switcher strip moves file focus; <kbd>Tab</kbd> cycles through form fields inside whichever card is active; <kbd>Esc</kbd> on the discard button opens the confirm dialog.</li>
|
||||
<li><strong>Focus management on file switch</strong>: when the user clicks a different file, the title input of the new file receives focus automatically (so the main editable field is always reachable).</li>
|
||||
<li><strong>Progress indicator during save</strong>: replace the save button with a determinate progress bar showing "Lade Datei 3 von 5…" for batches that take > 500ms.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="notes" style="margin-top:14px;border-left-color:#C0392B">
|
||||
<div class="nh" style="color:#C0392B">Edge cases + a11y</div>
|
||||
<ul>
|
||||
<li><strong>Duplicate filenames in the batch</strong>: accept, but show a warning icon next to both — backend will create both with unique IDs.</li>
|
||||
<li><strong>Mixed content types</strong>: PDF + image in the same batch is fine; the preview panel renders whichever the active file is (DocumentEditLayout already handles both).</li>
|
||||
<li><strong>Large batches (> 20 files)</strong>: the switcher strip becomes scrollable; consider a "Jump to file…" combobox at > 30 files (out of scope for v1).</li>
|
||||
<li><strong>Upload failure per file</strong>: mark the chip red (<code>bg-red-600/20 text-red-800 border border-red-600</code>), show inline error in the chip's tooltip, don't block the rest of the batch from retrying.</li>
|
||||
<li><strong>Screen reader announcement</strong>: when file count changes, fire a polite live region announce — "5 Dateien bereit zum Speichern" via <code>role="status" aria-live="polite"</code>.</li>
|
||||
<li><strong>Colour-alone warning</strong>: active file chip uses color + <code>aria-current="true"</code> + a ▸ caret prefix so it's distinguishable for color-blind users.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1684
docs/specs/bulk-upload-split-panel-spec.html
Normal file
1684
docs/specs/bulk-upload-split-panel-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
1146
docs/specs/transkriptions-richtlinien-spec.html
Normal file
1146
docs/specs/transkriptions-richtlinien-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
75
frontend/e2e/bulk-edit.spec.ts
Normal file
75
frontend/e2e/bulk-edit.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E coverage for the bulk metadata edit feature (issue #225).
|
||||
*
|
||||
* Assumptions:
|
||||
* - Auth setup has run as the admin user (WRITE_ALL).
|
||||
* - The backend exposes /api/documents/{bulk,batch-metadata,ids}.
|
||||
* - At least two documents exist in the search list at /documents.
|
||||
*/
|
||||
|
||||
test.describe('Bulk metadata edit', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/documents');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
});
|
||||
|
||||
test('checking two documents shows the sticky selection bar with the count', async ({ page }) => {
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
await expect(checkboxes.first()).toBeVisible();
|
||||
await checkboxes.nth(0).check();
|
||||
await checkboxes.nth(1).check();
|
||||
|
||||
const bar = page.getByTestId('bulk-selection-bar');
|
||||
await expect(bar).toBeVisible();
|
||||
await expect(page.getByTestId('bulk-selection-count')).toContainText('2');
|
||||
});
|
||||
|
||||
test('Alles aufheben hides the bar', async ({ page }) => {
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
await checkboxes.nth(0).check();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
|
||||
|
||||
await page.getByTestId('bulk-clear-all').click();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Massenbearbeitung navigates to bulk-edit with the selected documents', async ({ page }) => {
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
await checkboxes.nth(0).check();
|
||||
await checkboxes.nth(1).check();
|
||||
await page.getByTestId('bulk-edit-open').click();
|
||||
|
||||
await page.waitForURL('**/documents/bulk-edit');
|
||||
// Onboarding callout is the surest indicator the edit-mode layout rendered.
|
||||
await expect(page.getByTestId('bulk-edit-callout')).toBeVisible();
|
||||
});
|
||||
|
||||
test('navigating to /documents/bulk-edit with no selection redirects back to /documents', async ({
|
||||
page
|
||||
}) => {
|
||||
// Navigate directly without checking anything first.
|
||||
await page.goto('/documents/bulk-edit');
|
||||
await page.waitForURL('**/documents');
|
||||
expect(page.url()).toMatch(/\/documents(\?|$)/);
|
||||
});
|
||||
|
||||
test('the same selection bar drives the /enrich page', async ({ page }) => {
|
||||
await page.goto('/enrich');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// /enrich may legitimately be empty if every doc has metadata. In that
|
||||
// case there's nothing to bulk-select; skip.
|
||||
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
|
||||
const count = await checkboxes.count();
|
||||
test.skip(count === 0, 'No incomplete documents available on /enrich');
|
||||
|
||||
await checkboxes.first().check();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
|
||||
await expect(page.getByTestId('bulk-selection-count')).toContainText('1');
|
||||
|
||||
await page.getByTestId('bulk-clear-all').click();
|
||||
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
36
frontend/e2e/help-popover.spec.ts
Normal file
36
frontend/e2e/help-popover.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||
|
||||
test.describe('Help chip — Read/Edit panel header', () => {
|
||||
let docId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
docId = await createEmptyDocument(request);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await request.delete(`/api/documents/${docId}`);
|
||||
});
|
||||
|
||||
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
|
||||
// Use the accessible label of the HelpPopover trigger (transcription_mode_help_label)
|
||||
const helpBtn = page.getByRole('button', { name: 'Lese- und Bearbeitungsmodus' });
|
||||
await expect(helpBtn).toBeVisible({ timeout: 5000 });
|
||||
await helpBtn.click();
|
||||
|
||||
// Popover should open (role="region", not tooltip — click-triggered panels are regions)
|
||||
await expect(page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })).toBeVisible();
|
||||
|
||||
// Press Esc
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(
|
||||
page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
|
||||
).not.toBeVisible();
|
||||
|
||||
// Focus should have returned to the chip
|
||||
await expect(helpBtn).toBeFocused();
|
||||
});
|
||||
});
|
||||
30
frontend/e2e/helpers/upload-empty-document.ts
Normal file
30
frontend/e2e/helpers/upload-empty-document.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PDF_FIXTURE = path.resolve(__dirname, '../fixtures/minimal.pdf');
|
||||
|
||||
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
|
||||
const createRes = await request.post('/api/documents', {
|
||||
multipart: { title: 'E2E Transcribe Coach Test' }
|
||||
});
|
||||
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||
const doc = await createRes.json();
|
||||
const docId = doc.id as string;
|
||||
|
||||
const uploadRes = await request.put(`/api/documents/${docId}`, {
|
||||
multipart: {
|
||||
title: doc.title,
|
||||
file: {
|
||||
name: 'minimal.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||
|
||||
return docId;
|
||||
}
|
||||
74
frontend/e2e/richtlinien.spec.ts
Normal file
74
frontend/e2e/richtlinien.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||
}
|
||||
|
||||
test.describe('Richtlinien page — content', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/hilfe/transkription');
|
||||
});
|
||||
|
||||
test('renders h1 title, intro, five rules, four chips, closing card', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: /Transkriptions-Richtlinien/ })
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Damit alle Briefe einheitlich/)).toBeVisible();
|
||||
await expect(page.getByText('Nicht lesbare Wörter')).toBeVisible();
|
||||
await expect(page.getByText('Durchgestrichene Wörter')).toBeVisible();
|
||||
await expect(page.getByText(/Das lange s/)).toBeVisible();
|
||||
await expect(page.getByText('Unsichere Namen')).toBeVisible();
|
||||
await expect(page.getByText(/Dialekt/)).toBeVisible();
|
||||
await expect(page.getByText('Abkürzungen')).toBeVisible();
|
||||
await expect(page.getByText('Datumsformate')).toBeVisible();
|
||||
await expect(page.getByText(/Fehlt eine Regel/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('Wikipedia link opens in new tab with annotation', async ({ page }) => {
|
||||
const wikiLink = page.getByRole('link', { name: /Wikipedia/ });
|
||||
await expect(wikiLink).toHaveAttribute('target', '_blank');
|
||||
await expect(wikiLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
await expect(wikiLink).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||
await expect(wikiLink).toContainText(/öffnet in neuem Tab/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Richtlinien page — accessibility', () => {
|
||||
for (const viewport of [320, 768, 1440]) {
|
||||
test(`axe: light theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport, height: 800 });
|
||||
await page.goto('/hilfe/transkription');
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test(`axe: dark theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: viewport, height: 800 });
|
||||
await page.goto('/hilfe/transkription');
|
||||
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Richtlinien page — print media', () => {
|
||||
test('print snapshot hides nav, annotation chip, and new-tab spans', async ({ page }) => {
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
await page.goto('/hilfe/transkription');
|
||||
|
||||
const nav = page.locator('.app-nav');
|
||||
if ((await nav.count()) > 0) {
|
||||
await expect(nav).toBeHidden();
|
||||
}
|
||||
|
||||
// .new-tab annotation spans must be hidden in print so "(öffnet in neuem Tab)"
|
||||
// text does not clutter the printed output (the print CSS declares display:none)
|
||||
for (const span of await page.locator('.new-tab').all()) {
|
||||
await expect(span).toBeHidden();
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
105
frontend/e2e/transcribe-coach.spec.ts
Normal file
105
frontend/e2e/transcribe-coach.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { createEmptyDocument } from './helpers/upload-empty-document.js';
|
||||
|
||||
async function createBlock(
|
||||
request: Parameters<typeof createEmptyDocument>[0],
|
||||
docId: string
|
||||
): Promise<void> {
|
||||
const res = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||
data: {
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.1,
|
||||
text: 'Liebe Mutter,',
|
||||
label: null
|
||||
}
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Create block failed: ${res.status()}`);
|
||||
}
|
||||
|
||||
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||
}
|
||||
|
||||
test.describe('Transcribe coach — empty state', () => {
|
||||
let docId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
docId = await createEmptyDocument(request);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await request.delete(`/api/documents/${docId}`);
|
||||
});
|
||||
|
||||
test('shows coach card (title, preamble, three step bodies, animation region)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
await expect(page.getByText(/Kurrent-Erkenner lernt noch/)).toBeVisible();
|
||||
await expect(page.getByText(/Rahmen ziehen/)).toBeVisible();
|
||||
await expect(page.getByText(/Text eingeben/)).toBeVisible();
|
||||
await expect(page.getByText(/Speichert automatisch/)).toBeVisible();
|
||||
await expect(page.getByRole('img', { name: /Rahmen ziehen|Animation/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('training footer is NOT visible on empty doc', async ({ page }) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
// Toggle dark theme
|
||||
await page.getByRole('button', { name: /dark mode/i }).click();
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
const a11y = await buildAxe(page).analyze();
|
||||
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transcribe coach — with blocks', () => {
|
||||
let docId: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
docId = await createEmptyDocument(request);
|
||||
await createBlock(request, docId);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await request.delete(`/api/documents/${docId}`);
|
||||
});
|
||||
|
||||
test('training footer IS visible when at least one block exists', async ({ page }) => {
|
||||
await page.goto(`/documents/${docId}`);
|
||||
await page.getByRole('button', { name: 'Transkribieren' }).click();
|
||||
// Wait for blocks to finish loading — block count confirms mode settled to 'read'
|
||||
await expect(page.getByText(/1 Abschnitt/)).toBeVisible({ timeout: 5000 });
|
||||
await page.locator('[data-testid="mode-edit"]').click();
|
||||
await expect(page.getByText('Für Training vormerken')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,8 @@
|
||||
"nav_conversations": "Briefwechsel",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Abmelden",
|
||||
"theme_toggle_to_light": "Zu hellem Design wechseln",
|
||||
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
|
||||
"btn_save": "Speichern",
|
||||
"btn_cancel": "Abbrechen",
|
||||
"btn_confirm": "Bestätigen",
|
||||
@@ -33,6 +35,8 @@
|
||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||
"btn_back": "Zurück",
|
||||
"btn_back_to_document": "Zurück zum Dokument",
|
||||
"form_label_person_type": "Typ",
|
||||
"form_label_name": "Name",
|
||||
"form_label_first_name": "Vorname",
|
||||
"form_label_last_name": "Nachname",
|
||||
"form_label_alias": "Rufname / Alias",
|
||||
@@ -499,7 +503,7 @@
|
||||
"transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?",
|
||||
"transcription_block_history_btn": "Verlauf",
|
||||
"transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen",
|
||||
"transcription_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen",
|
||||
"transcription_next_block_cta": "Einen Rahmen ziehen, um Block {number} anzulegen",
|
||||
"transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren",
|
||||
"transcription_quote_stale": "Zitat aus älterer Version",
|
||||
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
|
||||
@@ -515,7 +519,6 @@
|
||||
"scan_collapse": "Scan verkleinern",
|
||||
"transcription_empty_title": "Noch keine Transkription",
|
||||
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
|
||||
"transcription_empty_draw_hint": "Zeichnen Sie Bereiche auf dem Dokument, um mit der Transkription zu beginnen.",
|
||||
"transcription_panel_close": "Panel schließen",
|
||||
"person_alias_heading": "Namensverlauf",
|
||||
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
|
||||
@@ -528,6 +531,7 @@
|
||||
"person_type_INSTITUTION": "Institution",
|
||||
"person_type_GROUP": "Gruppe",
|
||||
"person_type_UNKNOWN": "Unbekannt",
|
||||
"a11y_type_changed": "Typ geändert zu {type}",
|
||||
"person_alias_add_heading": "Name hinzufuegen",
|
||||
"person_alias_label_type": "Art",
|
||||
"person_alias_label_last_name": "Nachname",
|
||||
@@ -537,6 +541,9 @@
|
||||
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
||||
"person_alias_btn_delete": "Entfernen",
|
||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
|
||||
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
|
||||
@@ -806,5 +813,98 @@
|
||||
"chronik_load_more": "Mehr laden",
|
||||
"chronik_loading": "Lädt …",
|
||||
"chronik_load_more_announcement": "{count} weitere Einträge geladen",
|
||||
"chronik_view_all": "Alle Aktivitäten →"
|
||||
"chronik_view_all": "Alle Aktivitäten →",
|
||||
"pagination_prev": "Zurück",
|
||||
"pagination_next": "Weiter",
|
||||
"pagination_page_of": "Seite {page} von {total}",
|
||||
"pagination_nav_label": "Seitennavigation",
|
||||
|
||||
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||
|
||||
"transcribe_coach_title": "Erste Transkription?",
|
||||
"transcribe_coach_preamble": "Unser Kurrent-Erkenner lernt noch. Jede Transkription, die Sie zum Training freigeben, bringt ihm die Schrift bei — so funktioniert's:",
|
||||
"transcribe_coach_step_1_title": "Rahmen ziehen.",
|
||||
"transcribe_coach_step_1_body": "Klicken und ziehen Sie mit der Maus einen Rahmen um den Text, den Sie transkribieren möchten.",
|
||||
"transcribe_coach_step_2_title": "Text eingeben.",
|
||||
"transcribe_coach_step_2_body": "Geben Sie den Text, den Sie im Rahmen sehen, in das neue Textfeld ein.",
|
||||
"transcribe_coach_step_3_title": "Speichert automatisch.",
|
||||
"transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗",
|
||||
|
||||
"transcription_mode_help_label": "Lese- und Bearbeitungsmodus",
|
||||
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
|
||||
|
||||
"richtlinien_title": "Transkriptions-Richtlinien",
|
||||
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
||||
"richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.",
|
||||
"richtlinien_wiki_link": "Wikipedia",
|
||||
"richtlinien_rules_label": "Regeln für die Transkription",
|
||||
"richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter",
|
||||
"richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.",
|
||||
"richtlinien_rule_durchgestrichen_title": "Durchgestrichene Wörter",
|
||||
"richtlinien_rule_durchgestrichen_body": "Auch durchgestrichener Text gehört zum Brief. Schreiben Sie ihn in eckigen Klammern mit Präfix durchgestrichen:",
|
||||
"richtlinien_rule_langes_s_title": "Das lange s (ſ)",
|
||||
"richtlinien_rule_langes_s_body": "Das ſ ist nur eine alte Schriftform des Buchstabens s — kein eigener Laut. Schreiben Sie immer ein normales s.",
|
||||
"richtlinien_rule_name_title": "Unsichere Namen",
|
||||
"richtlinien_rule_name_body": "Wenn Sie einen Namen zu erkennen meinen, aber nicht sicher sind, ergänzen Sie ein Fragezeichen in eckigen Klammern.",
|
||||
"richtlinien_rule_dialekt_title": "Dialekt, Fremdwörter, fremde Zitate",
|
||||
"richtlinien_rule_dialekt_body": "Plattdeutsch, Französisch, lateinische Phrasen — wörtlich übernehmen, genau wie sie geschrieben stehen.",
|
||||
"richtlinien_beispiel_label": "Beispiel",
|
||||
"richtlinien_klaerung_label": "Noch in Klärung",
|
||||
"richtlinien_klaerung_intro": "Diese Fragen klären wir noch — stoßen Sie beim Transkribieren darauf, treffen Sie eine plausible Wahl und notieren Sie es in den Kommentaren:",
|
||||
"richtlinien_klaer_abkuerzungen": "Abkürzungen",
|
||||
"richtlinien_klaer_datumsformate": "Datumsformate",
|
||||
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
|
||||
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
|
||||
"richtlinien_closing_title": "Fehlt eine Regel?",
|
||||
"richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen.",
|
||||
"error_batch_too_large": "Zu viele Dateien auf einmal — bitte in Blöcken hochladen.",
|
||||
"bulk_drop_hint": "Eine oder mehrere Dateien ablegen",
|
||||
"bulk_drop_sub": "PDF · bis zu 50 MB pro Datei",
|
||||
"bulk_count_pill": "{count} werden erstellt",
|
||||
"bulk_save_cta_one": "Speichern →",
|
||||
"bulk_save_cta": "{count} speichern →",
|
||||
"bulk_discard_all": "Alle verwerfen",
|
||||
"bulk_discard_confirm": "Alle Dateien und eingegebenen Daten verwerfen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"bulk_add_more": "Weitere hinzufügen",
|
||||
"bulk_scope_per_file_label": "Nur diese Datei",
|
||||
"bulk_scope_shared_label": "Gilt für alle {count}",
|
||||
"bulk_title_suggested_hint": "Vorschlag aus Dateiname — zum Bearbeiten anklicken",
|
||||
"bulk_switcher_prev": "Vorherige Datei",
|
||||
"bulk_switcher_next": "Nächste Datei",
|
||||
"bulk_file_error_chip_label": "Fehler beim Hochladen",
|
||||
"bulk_upload_progress": "{done} von {total} hochgeladen",
|
||||
"bulk_partial_success": "{created} erstellt, {failed} fehlgeschlagen",
|
||||
"bulk_all_failed": "Alle Uploads fehlgeschlagen",
|
||||
"bulk_drop_desc": "Für jede Datei wird ein eigenes Dokument erstellt. Der Titel wird aus dem Dateinamen vorausgefüllt — alle anderen Felder gelten für alle gemeinsam.",
|
||||
"bulk_select_files": "Dateien auswählen",
|
||||
"bulk_drop_zone_label": "Dateien ablegen",
|
||||
"bulk_remove_file": "Entfernen",
|
||||
"bulk_title_single": "Neues Dokument",
|
||||
"bulk_title_multi": "Neue Dokumente",
|
||||
"bulk_edit_button": "Massenbearbeitung",
|
||||
"bulk_edit_n_selected_one": "1 Dokument ausgewählt",
|
||||
"bulk_edit_n_selected_other": "{count} Dokumente ausgewählt",
|
||||
"bulk_edit_clear_all": "Alles aufheben",
|
||||
"bulk_edit_all_x": "Alle {count} editieren",
|
||||
"bulk_edit_select_document": "Dokument {title} auswählen",
|
||||
"bulk_edit_hint": "Nur ausgefüllte Felder werden angewendet. Tags und Empfänger werden hinzugefügt, nicht ersetzt.",
|
||||
"bulk_edit_badge_additive": "+ wird hinzugefügt",
|
||||
"bulk_edit_badge_replace": "wird ersetzt",
|
||||
"bulk_edit_save_progress": "Batch {done} von {total} verarbeitet",
|
||||
"bulk_edit_save_partial": "{done} von {total} gespeichert",
|
||||
"bulk_edit_retry": "Erneut versuchen",
|
||||
"bulk_edit_title": "Massenbearbeitung",
|
||||
"bulk_edit_save_button": "Anwenden",
|
||||
"error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage.",
|
||||
"form_label_archive_box": "Karton",
|
||||
"form_helper_archive_box": "Welcher Karton im Archiv?",
|
||||
"form_label_archive_folder": "Mappe",
|
||||
"form_helper_archive_folder": "Welche Mappe innerhalb des Kartons?",
|
||||
"bulk_edit_clear_selection": "Auswahl aufheben",
|
||||
"bulk_edit_clear_hint_keyboard": "Esc: Auswahl aufheben",
|
||||
"bulk_edit_loading": "Dokumente werden geladen…",
|
||||
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
|
||||
"bulk_edit_topbar_title": "Massenbearbeitung",
|
||||
"bulk_edit_count_pill": "{count} werden bearbeitet"
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"nav_conversations": "Letters",
|
||||
"nav_admin": "Admin",
|
||||
"nav_logout": "Sign out",
|
||||
"theme_toggle_to_light": "Switch to light mode",
|
||||
"theme_toggle_to_dark": "Switch to dark mode",
|
||||
"btn_save": "Save",
|
||||
"btn_cancel": "Cancel",
|
||||
"btn_confirm": "Confirm",
|
||||
@@ -33,6 +35,8 @@
|
||||
"btn_back_to_overview": "Back to overview",
|
||||
"btn_back": "Back",
|
||||
"btn_back_to_document": "Back to document",
|
||||
"form_label_person_type": "Type",
|
||||
"form_label_name": "Name",
|
||||
"form_label_first_name": "First name",
|
||||
"form_label_last_name": "Last name",
|
||||
"form_label_alias": "Nickname / Alias",
|
||||
@@ -499,7 +503,7 @@
|
||||
"transcription_block_delete_confirm": "Really delete this block and all its comments?",
|
||||
"transcription_block_history_btn": "History",
|
||||
"transcription_empty_cta": "Mark a region on the scan to start transcribing",
|
||||
"transcription_next_block_cta": "Mark another passage on the scan to create block {number}",
|
||||
"transcription_next_block_cta": "Draw a frame on the scan to create block {number}",
|
||||
"transcription_draw_tooltip": "Click and drag to mark a text region",
|
||||
"transcription_quote_stale": "Quote from an older version",
|
||||
"transcription_block_conflict": "This block was changed by someone else — please reload",
|
||||
@@ -515,7 +519,6 @@
|
||||
"scan_collapse": "Collapse scan",
|
||||
"transcription_empty_title": "No transcription yet",
|
||||
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
|
||||
"transcription_empty_draw_hint": "Draw regions on the document to start transcribing.",
|
||||
"transcription_panel_close": "Close panel",
|
||||
"person_alias_heading": "Name history",
|
||||
"person_alias_empty": "No name changes recorded yet.",
|
||||
@@ -528,6 +531,7 @@
|
||||
"person_type_INSTITUTION": "Institution",
|
||||
"person_type_GROUP": "Group",
|
||||
"person_type_UNKNOWN": "Unknown",
|
||||
"a11y_type_changed": "Type changed to {type}",
|
||||
"person_alias_add_heading": "Add name",
|
||||
"person_alias_label_type": "Type",
|
||||
"person_alias_label_last_name": "Last name",
|
||||
@@ -537,6 +541,9 @@
|
||||
"person_alias_delete_body": "This name will be removed from search results.",
|
||||
"person_alias_btn_delete": "Remove",
|
||||
"error_alias_not_found": "The name alias was not found.",
|
||||
"error_invalid_person_type": "The specified person type is not valid.",
|
||||
"validation_last_name_required": "Last name is required.",
|
||||
"validation_first_name_required": "First name is required.",
|
||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||
"error_ocr_job_not_found": "The OCR job was not found.",
|
||||
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",
|
||||
@@ -806,5 +813,98 @@
|
||||
"chronik_load_more": "Load more",
|
||||
"chronik_loading": "Loading …",
|
||||
"chronik_load_more_announcement": "{count} more entries loaded",
|
||||
"chronik_view_all": "All activity →"
|
||||
"chronik_view_all": "All activity →",
|
||||
"pagination_prev": "Previous",
|
||||
"pagination_next": "Next",
|
||||
"pagination_page_of": "Page {page} of {total}",
|
||||
"pagination_nav_label": "Pagination",
|
||||
|
||||
"common_opens_new_tab": "(opens in new tab)",
|
||||
|
||||
"transcribe_coach_title": "First transcription?",
|
||||
"transcribe_coach_preamble": "Our Kurrent recogniser is still learning. Every transcription you release for training teaches it the handwriting — here's how it works:",
|
||||
"transcribe_coach_step_1_title": "Draw a frame.",
|
||||
"transcribe_coach_step_1_body": "Click and drag a frame around the text you want to transcribe.",
|
||||
"transcribe_coach_step_2_title": "Enter the text.",
|
||||
"transcribe_coach_step_2_body": "Type the text you see inside the frame into the new text field.",
|
||||
"transcribe_coach_step_3_title": "Saves automatically.",
|
||||
"transcribe_coach_footer_kurrent": "Kurrent help ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transcription guidelines ↗",
|
||||
|
||||
"transcription_mode_help_label": "Read and edit mode",
|
||||
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
||||
|
||||
"richtlinien_title": "Transcription Guidelines",
|
||||
"richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
||||
"richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.",
|
||||
"richtlinien_wiki_link": "Wikipedia",
|
||||
"richtlinien_rules_label": "Transcription rules",
|
||||
"richtlinien_rule_unleserlich_title": "Illegible words",
|
||||
"richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.",
|
||||
"richtlinien_rule_durchgestrichen_title": "Struck-through words",
|
||||
"richtlinien_rule_durchgestrichen_body": "Struck-through text still belongs to the letter. Write it in square brackets with prefix durchgestrichen:",
|
||||
"richtlinien_rule_langes_s_title": "The long s (ſ)",
|
||||
"richtlinien_rule_langes_s_body": "The ſ is just an old written form of the letter s — not a separate sound. Always write a normal s.",
|
||||
"richtlinien_rule_name_title": "Uncertain names",
|
||||
"richtlinien_rule_name_body": "If you think you can read a name but aren't sure, add a question mark in square brackets.",
|
||||
"richtlinien_rule_dialekt_title": "Dialect, foreign words, foreign quotes",
|
||||
"richtlinien_rule_dialekt_body": "Low German, French, Latin phrases — copy them verbatim, exactly as written.",
|
||||
"richtlinien_beispiel_label": "Example",
|
||||
"richtlinien_klaerung_label": "Still to be decided",
|
||||
"richtlinien_klaerung_intro": "These questions are still open — if you hit one while transcribing, make a plausible choice and note it in the comments:",
|
||||
"richtlinien_klaer_abkuerzungen": "Abbreviations",
|
||||
"richtlinien_klaer_datumsformate": "Date formats",
|
||||
"richtlinien_klaer_umbrueche": "Original line breaks",
|
||||
"richtlinien_klaer_caps": "Old capitalisation",
|
||||
"richtlinien_closing_title": "Missing a rule?",
|
||||
"richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering.",
|
||||
"error_batch_too_large": "Too many files at once — please upload in smaller batches.",
|
||||
"bulk_drop_hint": "Drop one or more files here",
|
||||
"bulk_drop_sub": "PDF · up to 50 MB per file",
|
||||
"bulk_count_pill": "{count} will be created",
|
||||
"bulk_save_cta_one": "Save →",
|
||||
"bulk_save_cta": "Save {count} →",
|
||||
"bulk_discard_all": "Discard all",
|
||||
"bulk_discard_confirm": "Discard all files and entered data? This action cannot be undone.",
|
||||
"bulk_add_more": "Add more",
|
||||
"bulk_scope_per_file_label": "This file only",
|
||||
"bulk_scope_shared_label": "Applies to all {count}",
|
||||
"bulk_title_suggested_hint": "Suggested from filename — click to edit",
|
||||
"bulk_switcher_prev": "Previous file",
|
||||
"bulk_switcher_next": "Next file",
|
||||
"bulk_file_error_chip_label": "Upload failed",
|
||||
"bulk_upload_progress": "{done} of {total} uploaded",
|
||||
"bulk_partial_success": "{created} created, {failed} failed",
|
||||
"bulk_all_failed": "All uploads failed",
|
||||
"bulk_drop_desc": "A separate document is created for each file. The title is pre-filled from the filename — all other fields apply to all documents.",
|
||||
"bulk_select_files": "Select files",
|
||||
"bulk_drop_zone_label": "Drop files here",
|
||||
"bulk_remove_file": "Remove",
|
||||
"bulk_title_single": "New Document",
|
||||
"bulk_title_multi": "New Documents",
|
||||
"bulk_edit_button": "Bulk edit",
|
||||
"bulk_edit_n_selected_one": "1 document selected",
|
||||
"bulk_edit_n_selected_other": "{count} documents selected",
|
||||
"bulk_edit_clear_all": "Clear all",
|
||||
"bulk_edit_all_x": "Edit all {count}",
|
||||
"bulk_edit_select_document": "Select document {title}",
|
||||
"bulk_edit_hint": "Only filled fields are applied. Tags and receivers are added, not replaced.",
|
||||
"bulk_edit_badge_additive": "+ will be added",
|
||||
"bulk_edit_badge_replace": "will replace",
|
||||
"bulk_edit_save_progress": "Batch {done} of {total} processed",
|
||||
"bulk_edit_save_partial": "{done} of {total} saved",
|
||||
"bulk_edit_retry": "Retry",
|
||||
"bulk_edit_title": "Bulk edit",
|
||||
"bulk_edit_save_button": "Apply",
|
||||
"error_bulk_edit_too_many_ids": "Maximum 500 documents per request.",
|
||||
"form_label_archive_box": "Box",
|
||||
"form_helper_archive_box": "Which box in the archive?",
|
||||
"form_label_archive_folder": "Folder",
|
||||
"form_helper_archive_folder": "Which folder inside the box?",
|
||||
"bulk_edit_clear_selection": "Clear selection",
|
||||
"bulk_edit_clear_hint_keyboard": "Esc: clear selection",
|
||||
"bulk_edit_loading": "Loading documents…",
|
||||
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
|
||||
"bulk_edit_topbar_title": "Bulk edit",
|
||||
"bulk_edit_count_pill": "{count} will be edited"
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"nav_conversations": "Cartas",
|
||||
"nav_admin": "Admin",
|
||||
"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_cancel": "Cancelar",
|
||||
"btn_confirm": "Confirmar",
|
||||
@@ -33,6 +35,8 @@
|
||||
"btn_back_to_overview": "Volver al resumen",
|
||||
"btn_back": "Volver",
|
||||
"btn_back_to_document": "Volver al documento",
|
||||
"form_label_person_type": "Tipo",
|
||||
"form_label_name": "Nombre",
|
||||
"form_label_first_name": "Nombre",
|
||||
"form_label_last_name": "Apellido",
|
||||
"form_label_alias": "Apodo / Alias",
|
||||
@@ -499,7 +503,7 @@
|
||||
"transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?",
|
||||
"transcription_block_history_btn": "Historial",
|
||||
"transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir",
|
||||
"transcription_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}",
|
||||
"transcription_next_block_cta": "Dibuje un marco en el escáner para crear el bloque {number}",
|
||||
"transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto",
|
||||
"transcription_quote_stale": "Cita de una versión anterior",
|
||||
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
|
||||
@@ -515,7 +519,6 @@
|
||||
"scan_collapse": "Reducir escaneo",
|
||||
"transcription_empty_title": "Sin transcripcion",
|
||||
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
|
||||
"transcription_empty_draw_hint": "Dibuje regiones en el documento para comenzar a transcribir.",
|
||||
"transcription_panel_close": "Cerrar panel",
|
||||
"person_alias_heading": "Historial de nombres",
|
||||
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
|
||||
@@ -528,6 +531,7 @@
|
||||
"person_type_INSTITUTION": "Institución",
|
||||
"person_type_GROUP": "Grupo",
|
||||
"person_type_UNKNOWN": "Desconocido",
|
||||
"a11y_type_changed": "Tipo cambiado a {type}",
|
||||
"person_alias_add_heading": "Agregar nombre",
|
||||
"person_alias_label_type": "Tipo",
|
||||
"person_alias_label_last_name": "Apellido",
|
||||
@@ -537,6 +541,9 @@
|
||||
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
||||
"person_alias_btn_delete": "Eliminar",
|
||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||
"validation_last_name_required": "El apellido es obligatorio.",
|
||||
"validation_first_name_required": "El nombre es obligatorio.",
|
||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
|
||||
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
|
||||
@@ -806,5 +813,98 @@
|
||||
"chronik_load_more": "Cargar más",
|
||||
"chronik_loading": "Cargando …",
|
||||
"chronik_load_more_announcement": "{count} entradas más cargadas",
|
||||
"chronik_view_all": "Todas las actividades →"
|
||||
"chronik_view_all": "Todas las actividades →",
|
||||
"pagination_prev": "Anterior",
|
||||
"pagination_next": "Siguiente",
|
||||
"pagination_page_of": "Página {page} de {total}",
|
||||
"pagination_nav_label": "Paginación",
|
||||
|
||||
"common_opens_new_tab": "(abre en pestaña nueva)",
|
||||
|
||||
"transcribe_coach_title": "¿Primera transcripción?",
|
||||
"transcribe_coach_preamble": "Nuestro reconocedor de Kurrent aún está aprendiendo. Cada transcripción que libera para el entrenamiento le enseña la escritura — así funciona:",
|
||||
"transcribe_coach_step_1_title": "Dibujar un marco.",
|
||||
"transcribe_coach_step_1_body": "Haga clic y arrastre un marco alrededor del texto que desea transcribir.",
|
||||
"transcribe_coach_step_2_title": "Ingresar el texto.",
|
||||
"transcribe_coach_step_2_body": "Escriba el texto que ve dentro del marco en el nuevo campo de texto.",
|
||||
"transcribe_coach_step_3_title": "Se guarda automáticamente.",
|
||||
"transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Normas de transcripción ↗",
|
||||
|
||||
"transcription_mode_help_label": "Modo lectura y edición",
|
||||
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
|
||||
|
||||
"richtlinien_title": "Normas de transcripción",
|
||||
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.",
|
||||
"richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.",
|
||||
"richtlinien_wiki_link": "Wikipedia",
|
||||
"richtlinien_rules_label": "Reglas de transcripción",
|
||||
"richtlinien_rule_unleserlich_title": "Palabras ilegibles",
|
||||
"richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.",
|
||||
"richtlinien_rule_durchgestrichen_title": "Palabras tachadas",
|
||||
"richtlinien_rule_durchgestrichen_body": "El texto tachado también pertenece a la carta. Escríbelo entre corchetes con el prefijo durchgestrichen:",
|
||||
"richtlinien_rule_langes_s_title": "La s larga (ſ)",
|
||||
"richtlinien_rule_langes_s_body": "La ſ es solo una forma antigua de la letra s. Escribe siempre una s normal.",
|
||||
"richtlinien_rule_name_title": "Nombres inciertos",
|
||||
"richtlinien_rule_name_body": "Si crees reconocer un nombre pero no estás seguro, añade un signo de interrogación entre corchetes.",
|
||||
"richtlinien_rule_dialekt_title": "Dialecto, palabras extranjeras, citas",
|
||||
"richtlinien_rule_dialekt_body": "Bajo alemán, francés, frases latinas — cópialas tal cual están escritas.",
|
||||
"richtlinien_beispiel_label": "Ejemplo",
|
||||
"richtlinien_klaerung_label": "Aún por decidir",
|
||||
"richtlinien_klaerung_intro": "Estas preguntas aún están abiertas — si encuentras alguna mientras transcribes, elige algo razonable y nótalo en los comentarios:",
|
||||
"richtlinien_klaer_abkuerzungen": "Abreviaturas",
|
||||
"richtlinien_klaer_datumsformate": "Formatos de fecha",
|
||||
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
|
||||
"richtlinien_klaer_caps": "Mayúsculas antiguas",
|
||||
"richtlinien_closing_title": "¿Falta una regla?",
|
||||
"richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar.",
|
||||
"error_batch_too_large": "Demasiados archivos a la vez — sube en lotes más pequeños.",
|
||||
"bulk_drop_hint": "Suelta uno o varios archivos aquí",
|
||||
"bulk_drop_sub": "PDF · hasta 50 MB por archivo",
|
||||
"bulk_count_pill": "Se crearán {count}",
|
||||
"bulk_save_cta_one": "Guardar →",
|
||||
"bulk_save_cta": "Guardar {count} →",
|
||||
"bulk_discard_all": "Descartar todo",
|
||||
"bulk_discard_confirm": "¿Descartar todos los archivos y datos introducidos? Esta acción no se puede deshacer.",
|
||||
"bulk_add_more": "Añadir más",
|
||||
"bulk_scope_per_file_label": "Solo este archivo",
|
||||
"bulk_scope_shared_label": "Para todos los {count}",
|
||||
"bulk_title_suggested_hint": "Sugerencia del nombre de archivo — haz clic para editar",
|
||||
"bulk_switcher_prev": "Archivo anterior",
|
||||
"bulk_switcher_next": "Archivo siguiente",
|
||||
"bulk_file_error_chip_label": "Error al subir",
|
||||
"bulk_upload_progress": "{done} de {total} subidos",
|
||||
"bulk_partial_success": "{created} creados, {failed} fallidos",
|
||||
"bulk_all_failed": "Todos los uploads fallaron",
|
||||
"bulk_drop_desc": "Se crea un documento separado por archivo. El título se rellena desde el nombre del archivo — el resto de campos se aplican a todos.",
|
||||
"bulk_select_files": "Seleccionar archivos",
|
||||
"bulk_drop_zone_label": "Soltar archivos aquí",
|
||||
"bulk_remove_file": "Eliminar",
|
||||
"bulk_title_single": "Nuevo Documento",
|
||||
"bulk_title_multi": "Nuevos Documentos",
|
||||
"bulk_edit_button": "Edición masiva",
|
||||
"bulk_edit_n_selected_one": "1 documento seleccionado",
|
||||
"bulk_edit_n_selected_other": "{count} documentos seleccionados",
|
||||
"bulk_edit_clear_all": "Limpiar todo",
|
||||
"bulk_edit_all_x": "Editar los {count}",
|
||||
"bulk_edit_select_document": "Seleccionar documento {title}",
|
||||
"bulk_edit_hint": "Solo se aplican los campos rellenados. Las etiquetas y los destinatarios se añaden, no se reemplazan.",
|
||||
"bulk_edit_badge_additive": "+ se añade",
|
||||
"bulk_edit_badge_replace": "se reemplaza",
|
||||
"bulk_edit_save_progress": "Lote {done} de {total} procesado",
|
||||
"bulk_edit_save_partial": "{done} de {total} guardado",
|
||||
"bulk_edit_retry": "Reintentar",
|
||||
"bulk_edit_title": "Edición masiva",
|
||||
"bulk_edit_save_button": "Aplicar",
|
||||
"error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud.",
|
||||
"form_label_archive_box": "Caja",
|
||||
"form_helper_archive_box": "¿Qué caja del archivo?",
|
||||
"form_label_archive_folder": "Carpeta",
|
||||
"form_helper_archive_folder": "¿Qué carpeta dentro de la caja?",
|
||||
"bulk_edit_clear_selection": "Limpiar selección",
|
||||
"bulk_edit_clear_hint_keyboard": "Esc: limpiar selección",
|
||||
"bulk_edit_loading": "Cargando documentos…",
|
||||
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
|
||||
"bulk_edit_topbar_title": "Edición masiva",
|
||||
"bulk_edit_count_pill": "Se editarán {count}"
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
|
||||
locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German
|
||||
reducedMotion: 'reduce', // prevents SMIL/CSS animations from flaking tests
|
||||
screenshot: 'on', // always capture screenshots
|
||||
video: 'retain-on-failure',
|
||||
trace: 'retain-on-failure'
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { thumbnailUrl } from '$lib/thumbnails';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Doc = {
|
||||
id: string;
|
||||
thumbnailKey?: string;
|
||||
thumbnailGeneratedAt?: string;
|
||||
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
|
||||
pageCount?: number;
|
||||
};
|
||||
type Doc = Pick<
|
||||
components['schemas']['Document'],
|
||||
'id' | 'thumbnailUrl' | 'thumbnailAspect' | 'pageCount'
|
||||
>;
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
|
||||
const url = $derived(thumbnailUrl(doc));
|
||||
const url = $derived(doc.thumbnailUrl ?? null);
|
||||
const aspect = $derived(doc.thumbnailAspect ?? 'PORTRAIT');
|
||||
const pageCount = $derived(doc.pageCount ?? 1);
|
||||
const tileClass = $derived(aspect === 'LANDSCAPE' ? 'h-[120px] w-[168px]' : 'h-[168px] w-[120px]');
|
||||
|
||||
@@ -12,8 +12,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: '1111',
|
||||
thumbnailKey: 'thumbnails/1111.jpg',
|
||||
thumbnailGeneratedAt: '2026-04-10T09:00:00Z',
|
||||
thumbnailUrl: '/api/documents/1111/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 1
|
||||
}
|
||||
@@ -29,7 +28,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'p1',
|
||||
thumbnailKey: 'thumbnails/p1.jpg',
|
||||
thumbnailUrl: '/api/documents/p1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 1
|
||||
}
|
||||
@@ -43,7 +42,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'l1',
|
||||
thumbnailKey: 'thumbnails/l1.jpg',
|
||||
thumbnailUrl: '/api/documents/l1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||
thumbnailAspect: 'LANDSCAPE',
|
||||
pageCount: 1
|
||||
}
|
||||
@@ -57,7 +56,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'n1',
|
||||
thumbnailKey: 'thumbnails/n1.jpg'
|
||||
thumbnailUrl: '/api/documents/n1/thumbnail?v=2026-04-10T09%3A00%3A00Z'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,7 +68,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'm1',
|
||||
thumbnailKey: 'thumbnails/m1.jpg',
|
||||
thumbnailUrl: '/api/documents/m1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 4
|
||||
}
|
||||
@@ -87,7 +86,7 @@ describe('ConversationThumbnail', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 's1',
|
||||
thumbnailKey: 'thumbnails/s1.jpg',
|
||||
thumbnailUrl: '/api/documents/s1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||
thumbnailAspect: 'PORTRAIT',
|
||||
pageCount: 1
|
||||
}
|
||||
@@ -97,7 +96,7 @@ describe('ConversationThumbnail', () => {
|
||||
expect(badge).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a skeleton placeholder when no thumbnailKey is set yet', () => {
|
||||
it('renders a skeleton placeholder when no thumbnailUrl is set yet', () => {
|
||||
render(ConversationThumbnail, {
|
||||
doc: {
|
||||
id: 'blank',
|
||||
|
||||
@@ -44,27 +44,41 @@ function safeColor(color: string): string {
|
||||
</div>
|
||||
{:else}
|
||||
<div data-testid="resume-strip" class="flex gap-4 rounded-sm border border-line bg-surface p-5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="180"
|
||||
height="246"
|
||||
viewBox="0 0 180 246"
|
||||
aria-hidden="true"
|
||||
class="shrink-0"
|
||||
<div
|
||||
class="relative h-[252px] w-[180px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="parchment" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f5f0e8" />
|
||||
<stop offset="100%" stop-color="#ede8d5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="180" height="246" fill="url(#parchment)" />
|
||||
<line x1="30" y1="40" x2="150" y2="40" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="70" x2="150" y2="70" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="100" x2="150" y2="100" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="130" x2="150" y2="130" stroke="#b0a898" stroke-width="1" />
|
||||
<line x1="30" y1="160" x2="150" y2="160" stroke="#b0a898" stroke-width="1" />
|
||||
</svg>
|
||||
{#if resumeDoc.thumbnailUrl}
|
||||
<img
|
||||
data-testid="resume-thumbnail-img"
|
||||
src={resumeDoc.thumbnailUrl}
|
||||
alt=""
|
||||
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
data-testid="resume-thumbnail-fallback"
|
||||
class="flex h-full w-full items-center justify-center text-ink-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-24 w-24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z M9 12.75h6M9 15.75h6M9 18.75h3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<p class="flex items-center gap-1.5 font-sans text-xs text-ink-3">
|
||||
|
||||
@@ -21,6 +21,11 @@ const mockResume: DashboardResumeDTO = {
|
||||
collaborators: []
|
||||
};
|
||||
|
||||
const mockResumeWithThumbnail: DashboardResumeDTO = {
|
||||
...mockResume,
|
||||
thumbnailUrl: '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00'
|
||||
};
|
||||
|
||||
describe('DashboardResumeStrip', () => {
|
||||
it('renders empty state heading when resumeDoc is null', async () => {
|
||||
render(DashboardResumeStrip, { resumeDoc: null });
|
||||
@@ -52,4 +57,23 @@ describe('DashboardResumeStrip', () => {
|
||||
const label = page.getByText(/4 Abschnitte/i);
|
||||
await expect.element(label).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders thumbnail img with expected attrs when thumbnailUrl is set', async () => {
|
||||
render(DashboardResumeStrip, { resumeDoc: mockResumeWithThumbnail });
|
||||
const img = page.getByTestId('resume-thumbnail-img');
|
||||
await expect.element(img).toBeInTheDocument();
|
||||
await expect
|
||||
.element(img)
|
||||
.toHaveAttribute('src', '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00');
|
||||
await expect.element(img).toHaveAttribute('alt', '');
|
||||
await expect.element(img).toHaveAttribute('loading', 'lazy');
|
||||
await expect.element(img).toHaveAttribute('decoding', 'async');
|
||||
});
|
||||
|
||||
it('renders fallback icon when thumbnailUrl is null', async () => {
|
||||
render(DashboardResumeStrip, { resumeDoc: mockResume });
|
||||
const fallback = page.getByTestId('resume-thumbnail-fallback');
|
||||
await expect.element(fallback).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('resume-thumbnail-img')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api';
|
||||
import { applyOffsets } from '$lib/search';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import ProgressRing from './ProgressRing.svelte';
|
||||
import ContributorStack from './ContributorStack.svelte';
|
||||
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
|
||||
let { item }: { item: DocumentSearchItem } = $props();
|
||||
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props();
|
||||
|
||||
const doc = $derived(item.document);
|
||||
const titleText = $derived(doc.title || doc.originalFilename);
|
||||
@@ -55,6 +56,21 @@ function safeTagColor(color: string | null | undefined): string {
|
||||
<a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a>
|
||||
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
|
||||
<div class="flex gap-3 sm:gap-5">
|
||||
<!-- Bulk-selection checkbox -->
|
||||
{#if canWrite}
|
||||
<label
|
||||
class="pointer-events-auto flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-start pt-1"
|
||||
data-testid="bulk-select-checkbox"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-brand-navy"
|
||||
checked={bulkSelectionStore.has(doc.id)}
|
||||
onchange={() => bulkSelectionStore.toggle(doc.id)}
|
||||
aria-label={m.bulk_edit_select_document({ title: titleText })}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
<!-- Thumbnail tile -->
|
||||
<DocumentThumbnail doc={doc} size="lg" />
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { goto } from '$app/navigation';
|
||||
import DocumentRow from './DocumentRow.svelte';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
@@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.mocked(goto).mockClear();
|
||||
bulkSelectionStore.clear();
|
||||
});
|
||||
|
||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||
@@ -265,6 +267,45 @@ describe('DocumentRow – tags', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bulk-selection checkbox ─────────────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – bulk selection checkbox', () => {
|
||||
it('does not render the checkbox when canWrite is false', async () => {
|
||||
render(DocumentRow, { item: makeItem(), canWrite: false });
|
||||
await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the checkbox when canWrite is true', async () => {
|
||||
render(DocumentRow, { item: makeItem(), canWrite: true });
|
||||
await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checkbox aria-label includes the document title', async () => {
|
||||
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } });
|
||||
render(DocumentRow, { item, canWrite: true });
|
||||
await expect
|
||||
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
|
||||
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } });
|
||||
render(DocumentRow, { item, canWrite: true });
|
||||
expect(bulkSelectionStore.has('doc-42')).toBe(false);
|
||||
|
||||
document.querySelector<HTMLInputElement>('input[type="checkbox"]')?.click();
|
||||
|
||||
await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true);
|
||||
});
|
||||
|
||||
it('checked state mirrors the store', async () => {
|
||||
bulkSelectionStore.add('doc-99');
|
||||
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } });
|
||||
render(DocumentRow, { item, canWrite: true });
|
||||
await expect.element(page.getByRole('checkbox')).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
|
||||
|
||||
describe('DocumentRow – progress ring and contributors', () => {
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { thumbnailUrl } from '$lib/thumbnails';
|
||||
|
||||
type Doc = Pick<
|
||||
components['schemas']['Document'],
|
||||
'id' | 'thumbnailKey' | 'thumbnailGeneratedAt' | 'contentType'
|
||||
>;
|
||||
type Doc = Pick<components['schemas']['Document'], 'id' | 'thumbnailUrl' | 'contentType'>;
|
||||
|
||||
let { doc, size = 'sm' }: { doc: Doc; size?: 'sm' | 'lg' } = $props();
|
||||
const url = $derived(thumbnailUrl(doc));
|
||||
const url = $derived(doc.thumbnailUrl ?? null);
|
||||
|
||||
const containerClass = $derived(
|
||||
size === 'lg'
|
||||
|
||||
101
frontend/src/lib/components/HelpPopover.svelte
Normal file
101
frontend/src/lib/components/HelpPopover.svelte
Normal file
@@ -0,0 +1,101 @@
|
||||
<script module>
|
||||
// Module-level counter produces stable, predictable IDs across SSR + hydration.
|
||||
// Math.random() would generate different values server-side vs client-side,
|
||||
// causing a hydration mismatch on first render.
|
||||
let _counter = 0;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Placement = 'bottom' | 'top' | 'left' | 'right';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
placement?: Placement;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { label, placement = 'bottom', children }: Props = $props();
|
||||
|
||||
const popoverId = `help-popover-${_counter++}`;
|
||||
|
||||
let open = $state(false);
|
||||
let triggerEl: HTMLButtonElement | null = $state(null);
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
triggerEl?.focus();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (triggerEl && (e.target === triggerEl || triggerEl.contains(e.target as Node))) return;
|
||||
const popoverEl = document.getElementById(popoverId);
|
||||
if (popoverEl && popoverEl.contains(e.target as Node)) return;
|
||||
open = false;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
};
|
||||
});
|
||||
|
||||
const placementClass: Record<Placement, string> = {
|
||||
bottom: 'top-full mt-1.5 left-1/2 -translate-x-1/2',
|
||||
top: 'bottom-full mb-1.5 left-1/2 -translate-x-1/2',
|
||||
left: 'right-full mr-1.5 top-1/2 -translate-y-1/2',
|
||||
right: 'left-full ml-1.5 top-1/2 -translate-y-1/2'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<!--
|
||||
Outer button is 44×44px for WCAG 2.5.8 touch-target compliance (our transcriber
|
||||
audience is 60+). The inner <span> carries the visual 20×20px circle.
|
||||
-->
|
||||
<button
|
||||
bind:this={triggerEl}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
aria-controls={popoverId}
|
||||
onclick={toggle}
|
||||
class="group flex h-[44px] w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
<span
|
||||
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors group-hover:border-brand-navy group-hover:text-brand-navy"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
id={popoverId}
|
||||
role="region"
|
||||
aria-label={label}
|
||||
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
|
||||
>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
114
frontend/src/lib/components/HelpPopover.svelte.spec.ts
Normal file
114
frontend/src/lib/components/HelpPopover.svelte.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import HelpPopover from './HelpPopover.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderPopover(label = 'Help') {
|
||||
return render(HelpPopover, { props: { label } });
|
||||
}
|
||||
|
||||
describe('HelpPopover — initial state', () => {
|
||||
it('renders a trigger button with the given label', async () => {
|
||||
renderPopover();
|
||||
const btn = page.getByRole('button', { name: /Help/ });
|
||||
await expect.element(btn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts closed: aria-expanded is false, popover not in DOM', async () => {
|
||||
renderPopover();
|
||||
const btn = page.getByRole('button', { name: /Help/ });
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(document.querySelector('[role="region"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HelpPopover — open / close interactions', () => {
|
||||
it('opens on click: aria-expanded true, popover in DOM', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
const btn = page.getByRole('button', { name: /Help/ });
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('closes on Esc key', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
|
||||
});
|
||||
|
||||
it('closes on outside click', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
expect(document.querySelector('[role="region"]')).not.toBeNull();
|
||||
|
||||
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
|
||||
});
|
||||
|
||||
it('opens on Enter key', async () => {
|
||||
renderPopover();
|
||||
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
it('opens on Space key', async () => {
|
||||
renderPopover();
|
||||
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
|
||||
await userEvent.keyboard('{Space}');
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe('HelpPopover — hover-target', () => {
|
||||
it('hover styles propagate from 44px button group to inner span, not from span itself', () => {
|
||||
const { container } = renderPopover();
|
||||
const btn = container.querySelector('button[aria-expanded]')!;
|
||||
const span = btn.querySelector('span')!;
|
||||
const btnClasses = btn.className.split(/\s+/);
|
||||
const spanClasses = span.className.split(/\s+/);
|
||||
expect(btnClasses).toContain('group');
|
||||
expect(spanClasses).not.toContain('hover:border-brand-navy');
|
||||
expect(spanClasses).toContain('group-hover:border-brand-navy');
|
||||
expect(spanClasses).not.toContain('hover:text-brand-navy');
|
||||
expect(spanClasses).toContain('group-hover:text-brand-navy');
|
||||
});
|
||||
|
||||
it('outer button has focus-visible ring for keyboard users', () => {
|
||||
const { container } = renderPopover();
|
||||
const btn = container.querySelector('button[aria-expanded]')!;
|
||||
expect(btn.className).toContain('focus-visible:ring-2');
|
||||
expect(btn.className).toContain('focus-visible:ring-brand-navy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HelpPopover — aria wiring', () => {
|
||||
it('trigger aria-controls matches popover element id', async () => {
|
||||
renderPopover();
|
||||
await page.getByRole('button', { name: /Help/ }).click();
|
||||
const btn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
const controls = btn.getAttribute('aria-controls');
|
||||
expect(controls).toBeTruthy();
|
||||
const popover = document.getElementById(controls!);
|
||||
expect(popover).not.toBeNull();
|
||||
});
|
||||
|
||||
it('two renders produce different, predictable IDs (no Math.random — SSR safe)', async () => {
|
||||
const { container: c1 } = render(HelpPopover, { props: { label: 'A' } });
|
||||
const { container: c2 } = render(HelpPopover, { props: { label: 'B' } });
|
||||
const id1 = c1.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
|
||||
const id2 = c2.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
|
||||
expect(id1).toBeTruthy();
|
||||
expect(id2).toBeTruthy();
|
||||
expect(id1).not.toBe(id2);
|
||||
// IDs must be deterministic (counter-based), not random hex
|
||||
expect(id1).toMatch(/^help-popover-\d+$/);
|
||||
expect(id2).toMatch(/^help-popover-\d+$/);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
bellButtonEl = node;
|
||||
return () => {
|
||||
@@ -72,12 +78,11 @@ onDestroy(() => {
|
||||
{@attach attachBellButton}
|
||||
type="button"
|
||||
onclick={toggleDropdown}
|
||||
aria-label={stream.unreadCount > 0
|
||||
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
||||
: m.notification_bell_label()}
|
||||
aria-label={bellLabel}
|
||||
title={bellLabel}
|
||||
aria-expanded={open}
|
||||
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
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -55,6 +55,34 @@ async function openDropdownAndClickFirstNotification() {
|
||||
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', () => {
|
||||
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
||||
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
||||
|
||||
80
frontend/src/lib/components/Pagination.svelte
Normal file
80
frontend/src/lib/components/Pagination.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
/** 0-indexed current page. */
|
||||
page: number;
|
||||
/** Total number of pages. `0` or `1` hides the control as trivially there's nothing to navigate. */
|
||||
totalPages: number;
|
||||
/** Given a 0-indexed page number, returns the href the link should point at. */
|
||||
makeHref: (page: number) => string;
|
||||
/** Optional override for the outer `<nav>`'s aria-label. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const { page, totalPages, makeHref, ariaLabel }: Props = $props();
|
||||
|
||||
const hasPrev = $derived(page > 0);
|
||||
const hasNext = $derived(page < totalPages - 1);
|
||||
const controlBase =
|
||||
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
|
||||
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
|
||||
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
|
||||
</script>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<nav
|
||||
aria-label={ariaLabel ?? m.pagination_nav_label()}
|
||||
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
|
||||
>
|
||||
<!--
|
||||
At the bounds we render a <span aria-hidden="true"> instead of an
|
||||
<a aria-disabled>. aria-disabled on a link is the documented pattern
|
||||
but screen readers still announce "Previous, link, disabled" — which
|
||||
is confusing on a pagination control where the disabled state is
|
||||
purely visual. Hiding the element from the AT tree entirely is the
|
||||
cleaner semantic.
|
||||
-->
|
||||
{#if hasPrev}
|
||||
<a
|
||||
data-testid="pagination-prev"
|
||||
aria-label={m.pagination_prev()}
|
||||
href={makeHref(page - 1)}
|
||||
class={linkBase}
|
||||
>
|
||||
<span aria-hidden="true">«</span>
|
||||
{m.pagination_prev()}
|
||||
</a>
|
||||
{:else}
|
||||
<span data-testid="pagination-prev" aria-hidden="true" class={disabledBase}>
|
||||
<span aria-hidden="true">«</span>
|
||||
{m.pagination_prev()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span
|
||||
data-testid="pagination-page-label"
|
||||
aria-current="page"
|
||||
class="font-sans text-sm text-ink-2"
|
||||
>
|
||||
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||
</span>
|
||||
|
||||
{#if hasNext}
|
||||
<a
|
||||
data-testid="pagination-next"
|
||||
aria-label={m.pagination_next()}
|
||||
href={makeHref(page + 1)}
|
||||
class={linkBase}
|
||||
>
|
||||
{m.pagination_next()}
|
||||
<span aria-hidden="true">»</span>
|
||||
</a>
|
||||
{:else}
|
||||
<span data-testid="pagination-next" aria-hidden="true" class={disabledBase}>
|
||||
{m.pagination_next()}
|
||||
<span aria-hidden="true">»</span>
|
||||
</span>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
86
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
86
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import Pagination from './Pagination.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeHref = (p: number) => `/documents?page=${p}`;
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('renders the page-of-total label for the current page', async () => {
|
||||
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
||||
|
||||
const label = page.getByTestId('pagination-page-label');
|
||||
await expect.element(label).toHaveTextContent(/3/); // page is 0-indexed, label is 1-indexed
|
||||
await expect.element(label).toHaveTextContent(/10/);
|
||||
});
|
||||
|
||||
it('marks the current page label with aria-current="page"', async () => {
|
||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||
|
||||
const label = page.getByTestId('pagination-page-label');
|
||||
await expect.element(label).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
|
||||
render(Pagination, { page: 4, totalPages: 10, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
|
||||
});
|
||||
|
||||
it('renders disabled prev as an aria-hidden non-link so screen readers skip it', async () => {
|
||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
// Not a link — no href, no role=link
|
||||
await expect.element(prev).not.toHaveAttribute('href');
|
||||
// Hidden from assistive tech — AT shouldn't read "Previous, link, disabled"
|
||||
await expect.element(prev).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('renders next as a link pointing at page + 1 when not on last page', async () => {
|
||||
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||
|
||||
const next = page.getByTestId('pagination-next');
|
||||
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
|
||||
});
|
||||
|
||||
it('renders disabled next as an aria-hidden non-link on the last page', async () => {
|
||||
render(Pagination, { page: 2, totalPages: 3, makeHref });
|
||||
|
||||
const next = page.getByTestId('pagination-next');
|
||||
await expect.element(next).not.toHaveAttribute('href');
|
||||
await expect.element(next).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('calls makeHref with p-1 and p+1', async () => {
|
||||
const spy = vi.fn(makeHref);
|
||||
render(Pagination, { page: 3, totalPages: 10, makeHref: spy });
|
||||
|
||||
const calls = spy.mock.calls.map((c) => c[0]).sort((a, b) => a - b);
|
||||
expect(calls).toContain(2);
|
||||
expect(calls).toContain(4);
|
||||
});
|
||||
|
||||
it('renders decorative chevron inside aria-hidden span so screen readers skip it', async () => {
|
||||
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
await expect
|
||||
.element(prev.getByText('«', { exact: true }))
|
||||
.toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('prev and next have min 44px touch targets', async () => {
|
||||
render(Pagination, { page: 1, totalPages: 3, makeHref });
|
||||
|
||||
const prev = page.getByTestId('pagination-prev');
|
||||
await expect.element(prev).toHaveClass(/min-h-\[44px\]/);
|
||||
await expect.element(prev).toHaveClass(/min-w-\[44px\]/);
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,7 @@ function removePerson(id: string | undefined) {
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
||||
>
|
||||
{#each selectedPersons as person (person.id)}
|
||||
<span
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
||||
import FieldLabelBadge from './document/FieldLabelBadge.svelte';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
@@ -18,6 +19,7 @@ interface Props {
|
||||
autofocus?: boolean;
|
||||
required?: boolean;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
badge?: 'additive' | 'replace';
|
||||
onchange?: (value: string) => void;
|
||||
onfocused?: () => void;
|
||||
}
|
||||
@@ -34,6 +36,7 @@ let {
|
||||
autofocus = false,
|
||||
required = false,
|
||||
restrictToCorrespondentsOf,
|
||||
badge,
|
||||
onchange,
|
||||
onfocused
|
||||
}: Props = $props();
|
||||
@@ -116,7 +119,7 @@ function selectPerson(person: Person) {
|
||||
class={compact
|
||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||
: 'block text-sm font-medium text-ink-2'}
|
||||
>{label}{#if required}*{/if}</label
|
||||
>{label}{#if required}*{/if}{#if badge}<FieldLabelBadge variant={badge} />{/if}</label
|
||||
>
|
||||
|
||||
<input type="hidden" name={name} bind:value={value} />
|
||||
@@ -134,7 +137,7 @@ function selectPerson(person: Person) {
|
||||
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: compact
|
||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
: 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
/>
|
||||
|
||||
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
||||
|
||||
46
frontend/src/lib/components/RichtlinienRuleCard.svelte
Normal file
46
frontend/src/lib/components/RichtlinienRuleCard.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
beispielInput?: string;
|
||||
beispielInputStrike?: boolean;
|
||||
beispielOutput?: string;
|
||||
beispielLabel?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
beispielInput,
|
||||
beispielInputStrike = false,
|
||||
beispielOutput,
|
||||
beispielLabel = 'Beispiel'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="border-brand-sand break-inside-avoid rounded-sm border bg-white p-5 shadow-sm">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<span aria-hidden="true" class="text-xl">{icon}</span>
|
||||
<h3 class="font-serif text-base font-bold text-ink">{title}</h3>
|
||||
</div>
|
||||
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
|
||||
|
||||
{#if beispielOutput !== undefined}
|
||||
<div class="border-brand-sand mt-4 rounded-sm border bg-parchment px-4 py-3">
|
||||
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
|
||||
{beispielLabel}
|
||||
</p>
|
||||
<p class="mt-1 font-sans text-sm text-ink">
|
||||
{#if beispielInput !== undefined}
|
||||
<code
|
||||
class={['font-mono', beispielInputStrike && 'line-through'].filter(Boolean).join(' ')}
|
||||
>{beispielInput}</code
|
||||
> →
|
||||
{/if}
|
||||
<code class="font-mono">{beispielOutput}</code>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import RichtlinienRuleCard from './RichtlinienRuleCard.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const defaultProps = {
|
||||
icon: '✍',
|
||||
title: 'Unleserliche Wörter',
|
||||
body: 'Schreiben Sie [unleserlich].',
|
||||
beispielOutput: '[unleserlich]'
|
||||
};
|
||||
|
||||
describe('RichtlinienRuleCard', () => {
|
||||
it('renders an h3 with the title', async () => {
|
||||
render(RichtlinienRuleCard, { props: defaultProps });
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 3 }))
|
||||
.toHaveTextContent('Unleserliche Wörter');
|
||||
});
|
||||
|
||||
it('renders the body text', async () => {
|
||||
render(RichtlinienRuleCard, { props: defaultProps });
|
||||
await expect.element(page.getByText('Schreiben Sie [unleserlich].')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon in a span with aria-hidden="true"', async () => {
|
||||
render(RichtlinienRuleCard, { props: defaultProps });
|
||||
const iconSpan = document.querySelector('span[aria-hidden="true"]');
|
||||
expect(iconSpan).not.toBeNull();
|
||||
expect(iconSpan!.textContent).toContain('✍');
|
||||
});
|
||||
|
||||
it('renders beispielOutput in monospace with → arrow', async () => {
|
||||
render(RichtlinienRuleCard, { props: defaultProps });
|
||||
const mono = document.querySelector('code, [class*="font-mono"]');
|
||||
expect(mono).not.toBeNull();
|
||||
expect(mono!.textContent).toContain('[unleserlich]');
|
||||
await expect.element(page.getByText(/→/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render beispiel section when beispielOutput is absent', async () => {
|
||||
render(RichtlinienRuleCard, {
|
||||
props: { icon: '✍', title: 'Test', body: 'Body' }
|
||||
});
|
||||
expect(document.querySelector('code, [class*="font-mono"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
@@ -19,6 +20,10 @@ onMount(() => {
|
||||
theme = resolveInitialTheme();
|
||||
});
|
||||
|
||||
const themeLabel = $derived(
|
||||
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
|
||||
);
|
||||
|
||||
function toggle() {
|
||||
theme = theme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('theme', theme);
|
||||
@@ -29,8 +34,8 @@ function toggle() {
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||
aria-label={themeLabel}
|
||||
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"
|
||||
>
|
||||
{#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'));
|
||||
});
|
||||
});
|
||||
76
frontend/src/lib/components/TranscribeCoachEmptyState.svelte
Normal file
76
frontend/src/lib/components/TranscribeCoachEmptyState.svelte
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||
</script>
|
||||
|
||||
<div class="border-brand-sand rounded-sm border bg-white p-7 shadow-sm">
|
||||
<h2 class="mb-3 font-serif text-[22px] font-bold text-ink">
|
||||
{m.transcribe_coach_title()}
|
||||
</h2>
|
||||
<p class="mb-6 font-serif text-[15px] leading-relaxed text-ink-2">
|
||||
{m.transcribe_coach_preamble()}
|
||||
</p>
|
||||
|
||||
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
|
||||
<!-- Step 1 -->
|
||||
<li aria-label="Schritt 1 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
>1</span
|
||||
>
|
||||
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||
<strong>{m.transcribe_coach_step_1_title()}</strong>
|
||||
{m.transcribe_coach_step_1_body()}
|
||||
<TranscribeDragDemo />
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<li aria-label="Schritt 2 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
>2</span
|
||||
>
|
||||
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||
<strong>{m.transcribe_coach_step_2_title()}</strong>
|
||||
{m.transcribe_coach_step_2_body()}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<li aria-label="Schritt 3 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
|
||||
>3</span
|
||||
>
|
||||
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
|
||||
<strong>{m.transcribe_coach_step_3_title()}</strong>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="border-brand-sand mt-6 flex flex-wrap gap-4 border-t pt-3.5 font-sans text-[13px]">
|
||||
<a
|
||||
href="https://de.wikipedia.org/wiki/Kurrent"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
referrerpolicy="no-referrer"
|
||||
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
>
|
||||
{m.transcribe_coach_footer_kurrent()}
|
||||
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
|
||||
</a>
|
||||
<a
|
||||
href="/hilfe/transkription"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
|
||||
>
|
||||
{m.transcribe_coach_footer_richtlinien()}
|
||||
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
|
||||
|
||||
vi.mock('$lib/paraglide/messages.js', () => ({
|
||||
m: {
|
||||
transcribe_coach_title: () => 'Erste Transkription?',
|
||||
transcribe_coach_preamble: () => 'Unser Kurrent-Erkenner lernt noch.',
|
||||
transcribe_coach_step_1_title: () => 'Rahmen ziehen.',
|
||||
transcribe_coach_step_1_body: () => 'Klicken und ziehen Sie mit der Maus einen Rahmen.',
|
||||
transcribe_coach_step_2_title: () => 'Text eingeben.',
|
||||
transcribe_coach_step_2_body: () => 'Geben Sie den Text ein.',
|
||||
transcribe_coach_step_3_title: () => 'Speichert automatisch.',
|
||||
transcribe_coach_footer_kurrent: () => 'Hilfe zu Kurrent ↗',
|
||||
transcribe_coach_footer_richtlinien: () => 'Transkriptions-Richtlinien ↗',
|
||||
common_opens_new_tab: () => '(öffnet in neuem Tab)'
|
||||
}
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('TranscribeCoachEmptyState', () => {
|
||||
it('renders the title and preamble', async () => {
|
||||
render(TranscribeCoachEmptyState);
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 2 }))
|
||||
.toHaveTextContent('Erste Transkription?');
|
||||
await expect.element(page.getByText('Unser Kurrent-Erkenner lernt noch.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders three numbered steps', async () => {
|
||||
render(TranscribeCoachEmptyState);
|
||||
await expect.element(page.getByText('Rahmen ziehen.')).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByText('Klicken und ziehen Sie mit der Maus einen Rahmen.'))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Text eingeben.')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Geben Sie den Text ein.')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Speichert automatisch.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footer links to Wikipedia Kurrent and Richtlinien page', async () => {
|
||||
render(TranscribeCoachEmptyState);
|
||||
const kurrentLink = page.getByRole('link', { name: /Hilfe zu Kurrent/ });
|
||||
await expect.element(kurrentLink).toBeInTheDocument();
|
||||
await expect.element(kurrentLink).toHaveAttribute('target', '_blank');
|
||||
await expect.element(kurrentLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
await expect.element(kurrentLink).toHaveAttribute('referrerpolicy', 'no-referrer');
|
||||
|
||||
const richtlinienLink = page.getByRole('link', { name: /Transkriptions-Richtlinien/ });
|
||||
await expect.element(richtlinienLink).toBeInTheDocument();
|
||||
await expect.element(richtlinienLink).toHaveAttribute('target', '_blank');
|
||||
await expect.element(richtlinienLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('renders visible "(öffnet in neuem Tab)" annotation on each footer link', async () => {
|
||||
render(TranscribeCoachEmptyState);
|
||||
const annotations = page.getByText('(öffnet in neuem Tab)');
|
||||
await expect.element(annotations.first()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the drag demo animation region inside step 1', async () => {
|
||||
render(TranscribeCoachEmptyState);
|
||||
const demo = page.getByRole('img', { name: /Rahmen ziehen|Animation/i });
|
||||
await expect.element(demo).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
217
frontend/src/lib/components/TranscribeDragDemo.svelte
Normal file
217
frontend/src/lib/components/TranscribeDragDemo.svelte
Normal file
@@ -0,0 +1,217 @@
|
||||
<script lang="ts">
|
||||
// $derived from .matches is a one-shot snapshot — it doesn't react when the
|
||||
// user toggles the OS setting at runtime. Use $state + addEventListener instead.
|
||||
let prefersReducedMotion = $state(
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
prefersReducedMotion = e.matches;
|
||||
};
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if prefersReducedMotion}
|
||||
<!-- Static final frame for reduced-motion users -->
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
|
||||
viewBox="0 0 600 180"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-parchment"
|
||||
>
|
||||
<g
|
||||
stroke="#2a2a2a"
|
||||
stroke-width="1.6"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
|
||||
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
|
||||
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
|
||||
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
|
||||
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
|
||||
</g>
|
||||
<line
|
||||
x1="60"
|
||||
y1="120"
|
||||
x2="540"
|
||||
y2="120"
|
||||
stroke="#D4D1C4"
|
||||
stroke-width="0.8"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
<rect
|
||||
x="55"
|
||||
y="68"
|
||||
width="470"
|
||||
height="57"
|
||||
fill="rgba(166, 218, 216, 0.12)"
|
||||
stroke="#002850"
|
||||
stroke-width="2.2"
|
||||
/>
|
||||
<g transform="translate(515, 58)">
|
||||
<circle cx="0" cy="0" r="9" fill="#002850" />
|
||||
<path
|
||||
d="M -4 0 L -1 3 L 4 -3"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Animated 5-second drawing loop -->
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint."
|
||||
viewBox="0 0 600 180"
|
||||
class="border-brand-sand block w-full rounded-sm border bg-parchment"
|
||||
>
|
||||
<!-- Kurrent writing (static) -->
|
||||
<g
|
||||
stroke="#2a2a2a"
|
||||
stroke-width="1.6"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
|
||||
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
|
||||
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
|
||||
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
|
||||
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
|
||||
</g>
|
||||
<line
|
||||
x1="60"
|
||||
y1="120"
|
||||
x2="540"
|
||||
y2="120"
|
||||
stroke="#D4D1C4"
|
||||
stroke-width="0.8"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
|
||||
<!-- Click ripple -->
|
||||
<circle cx="55" cy="68" r="0" fill="none" stroke="#A6DAD8" stroke-width="2.5" opacity="0">
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="0;0;4;18;0;0"
|
||||
keyTimes="0;0.17;0.19;0.24;0.26;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0;1;0;0;0"
|
||||
keyTimes="0;0.17;0.19;0.24;0.26;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
<!-- Growing selection rectangle -->
|
||||
<rect
|
||||
x="55"
|
||||
y="68"
|
||||
width="0"
|
||||
height="0"
|
||||
fill="rgba(166, 218, 216, 0.12)"
|
||||
stroke="#002850"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="5 4"
|
||||
opacity="0"
|
||||
>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0;1;1;1;1;0;0"
|
||||
keyTimes="0;0.18;0.20;0.88;0.92;0.94;0.98;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="width"
|
||||
values="0;0;470;470;470;470;0"
|
||||
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="height"
|
||||
values="0;0;57;57;57;57;0"
|
||||
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-dasharray"
|
||||
values="5 4;5 4;5 4;1 0;1 0;5 4"
|
||||
keyTimes="0;0.60;0.64;0.68;0.94;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-width"
|
||||
values="2;2;2;3.2;2.2;2;2"
|
||||
keyTimes="0;0.64;0.66;0.68;0.72;0.90;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
|
||||
<!-- Confirmation checkmark badge -->
|
||||
<g opacity="0" transform="translate(515, 58)">
|
||||
<circle cx="0" cy="0" r="9" fill="#002850" />
|
||||
<path
|
||||
d="M -4 0 L -1 3 L 4 -3"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;0;1;1;0;0"
|
||||
keyTimes="0;0.66;0.70;0.86;0.92;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Cursor arrow -->
|
||||
<g>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="translate"
|
||||
values="15,20; 55,68; 55,68; 525,125; 525,125; 15,20"
|
||||
keyTimes="0; 0.15; 0.20; 0.62; 0.92; 1"
|
||||
calcMode="spline"
|
||||
keySplines="0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="1;1;1;0;0;1"
|
||||
keyTimes="0;0.92;0.94;0.96;0.99;1"
|
||||
dur="5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<path
|
||||
d="M 0 0 L 0 16 L 4.5 12 L 7.5 18 L 10.5 16.6 L 7.8 10.6 L 13 9 Z"
|
||||
fill="#002850"
|
||||
stroke="white"
|
||||
stroke-width="0.8"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscribeDragDemo from './TranscribeDragDemo.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('TranscribeDragDemo', () => {
|
||||
it('renders an SVG with an aria-label describing the animation', async () => {
|
||||
render(TranscribeDragDemo);
|
||||
const svg = page.getByRole('img');
|
||||
await expect.element(svg).toBeInTheDocument();
|
||||
await expect.element(svg).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('contains a dashed-border rectangle animation element', async () => {
|
||||
const { container } = render(TranscribeDragDemo);
|
||||
const rect = container.querySelector('rect');
|
||||
expect(rect).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||
import OcrTrigger from './OcrTrigger.svelte';
|
||||
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
||||
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
||||
@@ -231,28 +232,12 @@ async function handleLabelToggle(label: string) {
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<svg
|
||||
class="mb-4 h-16 w-16 text-ink-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p class="max-w-xs text-sm leading-relaxed text-ink-3">
|
||||
{m.transcription_empty_draw_hint()}
|
||||
</p>
|
||||
<div class="p-4">
|
||||
<TranscribeCoachEmptyState />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if canWrite}
|
||||
{#if canWrite && hasBlocks}
|
||||
<div class="border-t border-line px-4 py-3">
|
||||
<p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
||||
@@ -61,9 +61,21 @@ describe('TranscriptionEditView — rendering', () => {
|
||||
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no blocks', async () => {
|
||||
it('shows coach card when no blocks', async () => {
|
||||
renderView({ blocks: [] });
|
||||
await expect.element(page.getByText(/Zeichnen Sie Bereiche/)).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 2 }))
|
||||
.toHaveTextContent('Erste Transkription?');
|
||||
});
|
||||
|
||||
it('hides training footer when no blocks', async () => {
|
||||
renderView({ blocks: [], canWrite: true });
|
||||
await expect.element(page.getByText('Für Training vormerken')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows training footer when blocks exist', async () => {
|
||||
renderView({ blocks: [block1], canWrite: true });
|
||||
await expect.element(page.getByText('Für Training vormerken')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import HelpPopover from './HelpPopover.svelte';
|
||||
|
||||
type Props = {
|
||||
mode: 'read' | 'edit';
|
||||
@@ -33,31 +34,36 @@ function handleReadClick() {
|
||||
<div
|
||||
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
||||
>
|
||||
<!-- Segmented toggle -->
|
||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-read"
|
||||
aria-disabled={!hasBlocks}
|
||||
onclick={handleReadClick}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||
<!-- Segmented toggle + help chip -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-read"
|
||||
aria-disabled={!hasBlocks}
|
||||
onclick={handleReadClick}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||
>
|
||||
{m.mode_read()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-edit"
|
||||
onclick={() => onModeChange('edit')}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||
>
|
||||
{m.mode_read()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-edit"
|
||||
onclick={() => onModeChange('edit')}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
>
|
||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||
</button>
|
||||
>
|
||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||
</button>
|
||||
</div>
|
||||
<HelpPopover label={m.transcription_mode_help_label()}>
|
||||
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
|
||||
</HelpPopover>
|
||||
</div>
|
||||
|
||||
<!-- Status line (hidden on mobile to save space) -->
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('TranscriptionPanelHeader', () => {
|
||||
it('should render Lesen and Bearbeiten buttons', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
@@ -148,4 +150,33 @@ describe('TranscriptionPanelHeader', () => {
|
||||
expect(statusText).not.toBeNull();
|
||||
expect(statusText!.textContent).toContain('2026');
|
||||
});
|
||||
|
||||
it('renders a (?) help chip next to the Read/Edit toggle', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
expect(helpBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens a help popover with mode explanation when the chip is clicked', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
import BulkDropZone from './BulkDropZone.svelte';
|
||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||
import ScopeCard from './ScopeCard.svelte';
|
||||
import UploadSaveBar from './UploadSaveBar.svelte';
|
||||
import WhoWhenSection from './WhoWhenSection.svelte';
|
||||
import DescriptionSection from './DescriptionSection.svelte';
|
||||
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||
import { bulkTitleFromFilename } from '$lib/utils/filename';
|
||||
import type { Tag } from '$lib/components/TagInput.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
// Mirrors the backend `DocumentBatchSummary` JSON shape one-to-one — the route
|
||||
// passes the parsed `/api/documents/batch-metadata` response straight in, so
|
||||
// the field names must match what the backend actually serializes (id, not
|
||||
// documentId). The FileEntry built from each summary still uses both `id` and
|
||||
// `documentId` so the save handler can drive the PATCH payload by UUID.
|
||||
export type BulkEditEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
pdfUrl: string;
|
||||
};
|
||||
|
||||
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
|
||||
let _confirmService: ConfirmService | null;
|
||||
try {
|
||||
_confirmService = getConfirmService();
|
||||
} catch {
|
||||
_confirmService = null;
|
||||
}
|
||||
|
||||
let {
|
||||
mode = 'upload',
|
||||
initialSenderId = '',
|
||||
initialSenderName = '',
|
||||
initialReceivers = [],
|
||||
initialEditEntries = []
|
||||
}: {
|
||||
mode?: 'upload' | 'edit';
|
||||
initialSenderId?: string;
|
||||
initialSenderName?: string;
|
||||
initialReceivers?: Person[];
|
||||
initialEditEntries?: BulkEditEntry[];
|
||||
} = $props();
|
||||
|
||||
// --- File state ---
|
||||
let files = new SvelteMap<string, FileEntry>();
|
||||
let activeId = $state<string | null>(null);
|
||||
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
||||
let saving = $state(false);
|
||||
// Partial-failure surface: when set, the last save aborted at chunk N of M.
|
||||
let partialSaved = $state<{ done: number; total: number } | null>(null);
|
||||
|
||||
// --- Shared metadata ---
|
||||
let senderId = $state(untrack(() => initialSenderId));
|
||||
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
|
||||
let dateIso = $state('');
|
||||
let tags = $state<Tag[]>([]);
|
||||
// Bulk-edit only — replace-on-non-blank semantics.
|
||||
let archiveBox = $state('');
|
||||
let archiveFolder = $state('');
|
||||
|
||||
// Hydrate edit-mode entries on mount. The IDs in bulkSelectionStore drive the
|
||||
// fetch upstream in the route — by the time this layout mounts, the metadata
|
||||
// has already been resolved into `initialEditEntries`. Wrapped in onMount so
|
||||
// the SvelteMap mutation is unambiguously tied to instance lifecycle, not to
|
||||
// the script body's first execution (Felix C4 cycle 3).
|
||||
onMount(() => {
|
||||
if (mode !== 'edit') return;
|
||||
for (const entry of untrack(() => initialEditEntries)) {
|
||||
files.set(entry.id, {
|
||||
id: entry.id,
|
||||
documentId: entry.id,
|
||||
title: entry.title,
|
||||
status: 'idle',
|
||||
previewUrl: entry.pdfUrl
|
||||
});
|
||||
if (!activeId) activeId = entry.id;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Derived ---
|
||||
const isMulti = $derived(files.size >= 2);
|
||||
const activeFile = $derived(activeId ? files.get(activeId) : null);
|
||||
|
||||
// --- File management ---
|
||||
function addFiles(newFiles: File[]) {
|
||||
for (const file of newFiles) {
|
||||
const id = crypto.randomUUID();
|
||||
const title = bulkTitleFromFilename(file.name);
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
files.set(id, { id, file, title, status: 'idle', previewUrl });
|
||||
if (!activeId) activeId = id;
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(id: string) {
|
||||
const entry = files.get(id);
|
||||
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||
files.delete(id);
|
||||
if (activeId === id) {
|
||||
activeId = files.keys().next().value ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function setTitle(id: string, title: string) {
|
||||
const entry = files.get(id);
|
||||
if (entry) files.set(id, { ...entry, title });
|
||||
}
|
||||
|
||||
function discardAll() {
|
||||
for (const entry of files.values()) {
|
||||
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||
}
|
||||
files.clear();
|
||||
activeId = null;
|
||||
chunkProgress = undefined;
|
||||
}
|
||||
|
||||
async function handleDiscard() {
|
||||
if (_confirmService) {
|
||||
const ok = await _confirmService.confirm({
|
||||
title: m.bulk_discard_all(),
|
||||
body: m.bulk_discard_confirm(),
|
||||
destructive: true
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
if (mode === 'edit') {
|
||||
// In edit mode the file map IS the user's bulk selection — discarding
|
||||
// must clear the upstream store and bounce back to the list, otherwise
|
||||
// the user is left on /documents/bulk-edit with an empty form and a
|
||||
// stale count in the bottom bar (issue #225 Bulk-Edit Panel table).
|
||||
bulkSelectionStore.clear();
|
||||
discardAll();
|
||||
await goto('/documents');
|
||||
return;
|
||||
}
|
||||
discardAll();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
for (const entry of files.values()) {
|
||||
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Save (upload mode) ---
|
||||
async function saveUpload() {
|
||||
const entries = Array.from(files.values());
|
||||
// 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF).
|
||||
const chunkSize = 10;
|
||||
const chunks: FileEntry[][] = [];
|
||||
for (let i = 0; i < entries.length; i += chunkSize) {
|
||||
chunks.push(entries.slice(i, i + chunkSize));
|
||||
}
|
||||
chunkProgress = { done: 0, total: chunks.length };
|
||||
|
||||
let hadErrors = false;
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
const formData = new FormData();
|
||||
chunk.forEach((entry) => entry.file && formData.append('files', entry.file));
|
||||
const metadata = {
|
||||
titles: chunk.map((e) => e.title),
|
||||
senderId: senderId || null,
|
||||
receiverIds: selectedReceivers.map((r) => r.id),
|
||||
documentDate: dateIso || null,
|
||||
tagNames: tags.map((t) => t.name)
|
||||
};
|
||||
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
||||
// Raw fetch is intentional: SvelteKit form actions can't stream chunked
|
||||
// FormData with per-chunk progress. Session cookie is sent automatically
|
||||
// by the browser for same-origin requests.
|
||||
try {
|
||||
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
||||
const body = await res.json().catch(() => ({ errors: [] }));
|
||||
const errorFilenames = new Set<string>(
|
||||
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
||||
);
|
||||
if (!res.ok || errorFilenames.size > 0) {
|
||||
hadErrors = true;
|
||||
for (const entry of chunk) {
|
||||
const filename = entry.file?.name;
|
||||
const isError = errorFilenames.size > 0 && filename ? errorFilenames.has(filename) : true;
|
||||
if (isError) {
|
||||
const e = files.get(entry.id);
|
||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
hadErrors = true;
|
||||
for (const entry of chunk) {
|
||||
const e = files.get(entry.id);
|
||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||
}
|
||||
}
|
||||
chunkProgress = { done: i + 1, total: chunks.length };
|
||||
}
|
||||
if (!hadErrors) goto('/documents');
|
||||
}
|
||||
|
||||
// --- Save (edit mode) ---
|
||||
async function saveBulkEdit() {
|
||||
const entries = Array.from(files.values());
|
||||
const ids = entries.map((e) => e.documentId).filter((x): x is string => !!x);
|
||||
|
||||
// PATCH cap matches backend: 500 IDs per request. Sequential, stop on chunk
|
||||
// failure so the user sees a deterministic "X of N saved" outcome.
|
||||
const chunkSize = 500;
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < ids.length; i += chunkSize) {
|
||||
chunks.push(ids.slice(i, i + chunkSize));
|
||||
}
|
||||
chunkProgress = { done: 0, total: chunks.length };
|
||||
partialSaved = null;
|
||||
|
||||
const dto = {
|
||||
tagNames: tags.map((t) => t.name),
|
||||
senderId: senderId || null,
|
||||
receiverIds: selectedReceivers.map((r) => r.id),
|
||||
archiveBox: archiveBox || null,
|
||||
archiveFolder: archiveFolder || null
|
||||
};
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
try {
|
||||
const res = await fetch('/api/documents/bulk', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...dto, documentIds: chunk })
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Network/server failure: the chunk did not apply. Mark its entries
|
||||
// as errored, surface partial-save state, and stop.
|
||||
for (const id of chunk) {
|
||||
const e = files.get(id);
|
||||
if (e) files.set(id, { ...e, status: 'error' });
|
||||
}
|
||||
partialSaved = { done: i, total: chunks.length };
|
||||
return;
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as {
|
||||
updated: number;
|
||||
errors: { id: string; message: string }[];
|
||||
} | null;
|
||||
if (body && body.errors && body.errors.length > 0) {
|
||||
for (const err of body.errors) {
|
||||
const e = files.get(err.id);
|
||||
if (e) files.set(err.id, { ...e, status: 'error' });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
for (const id of chunk) {
|
||||
const e = files.get(id);
|
||||
if (e) files.set(id, { ...e, status: 'error' });
|
||||
}
|
||||
partialSaved = { done: i, total: chunks.length };
|
||||
return;
|
||||
}
|
||||
chunkProgress = { done: i + 1, total: chunks.length };
|
||||
}
|
||||
|
||||
const stillErrored = Array.from(files.values()).some((e) => e.status === 'error');
|
||||
if (!stillErrored) {
|
||||
bulkSelectionStore.clear();
|
||||
goto('/documents');
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
if (mode === 'edit') {
|
||||
await saveBulkEdit();
|
||||
} else {
|
||||
await saveUpload();
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retrySave() {
|
||||
partialSaved = null;
|
||||
await save();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: var(--header-height)">
|
||||
<!-- Topbar -->
|
||||
<div class="flex shrink-0 items-center gap-3 border-b border-line bg-surface px-6 py-3">
|
||||
<a
|
||||
href="/documents"
|
||||
class="flex items-center gap-1.5 text-xs font-bold tracking-widest text-ink-3 uppercase hover:text-ink"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
{m.btn_back_to_overview()}
|
||||
</a>
|
||||
<span class="text-ink-3" aria-hidden="true">·</span>
|
||||
<span class="font-serif text-sm font-bold text-ink">
|
||||
{#if mode === 'edit'}
|
||||
{m.bulk_edit_topbar_title()}
|
||||
{:else}
|
||||
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
|
||||
{/if}
|
||||
</span>
|
||||
{#if isMulti}
|
||||
<span class="ml-auto flex items-center gap-3">
|
||||
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
|
||||
{#if mode === 'edit'}
|
||||
{m.bulk_edit_count_pill({ count: files.size })}
|
||||
{:else}
|
||||
{m.bulk_count_pill({ count: files.size })}
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="discard-all-btn"
|
||||
onclick={handleDiscard}
|
||||
class="text-xs font-medium text-red-600/70 hover:text-red-700"
|
||||
>
|
||||
{m.bulk_discard_all()}
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Split panel -->
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left: PDF preview / drop zone (55%) -->
|
||||
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
|
||||
{#if mode === 'upload' && files.size === 0}
|
||||
<!-- N=0: centred drop-zone box fills the panel (upload only) -->
|
||||
<BulkDropZone onFilesAdded={addFiles} />
|
||||
{:else if files.size > 0}
|
||||
<!-- PDF preview: blob URL in upload mode, server URL in edit mode -->
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
{#if activeFile}
|
||||
<PdfViewer url={activeFile.previewUrl} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if isMulti}
|
||||
<!-- File switcher strip pinned to bottom of left panel -->
|
||||
<FileSwitcherStrip
|
||||
files={Array.from(files.values())}
|
||||
activeId={activeId ?? ''}
|
||||
onSelect={(id) => (activeId = id)}
|
||||
onRemove={removeFile}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: metadata form (45%) -->
|
||||
<div class="flex flex-[45] flex-col overflow-hidden">
|
||||
<!-- Scrollable form area — greyed out and non-interactive when no files selected -->
|
||||
<div
|
||||
class="flex-1 space-y-4 overflow-y-auto p-4 transition-opacity"
|
||||
class:opacity-60={files.size === 0}
|
||||
class:pointer-events-none={files.size === 0}
|
||||
>
|
||||
{#if mode === 'edit'}
|
||||
<!-- Onboarding callout: tells the user that empty fields are skipped
|
||||
and that tags/receivers are added rather than replaced.
|
||||
No aria-label — role=note + the visible text content is
|
||||
self-describing; an aria-label would override that text for
|
||||
AT users on non-DE locales. -->
|
||||
<div
|
||||
role="note"
|
||||
data-testid="bulk-edit-callout"
|
||||
class="rounded-sm border border-accent/40 bg-accent/15 px-4 py-3 text-sm text-ink-2"
|
||||
>
|
||||
{m.bulk_edit_hint()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isMulti}
|
||||
<!-- N≥2: per-file card (title) + shared card (metadata) -->
|
||||
<ScopeCard variant="per-file">
|
||||
{#if activeFile}
|
||||
{#if mode === 'edit'}
|
||||
<div data-testid="readonly-title">
|
||||
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||
{m.form_label_title()}
|
||||
</span>
|
||||
<p class="font-serif text-base text-ink">{activeFile.title}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
|
||||
{m.form_label_title()} <span class="text-danger">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={activeFile.title}
|
||||
oninput={(e) =>
|
||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
{/if}
|
||||
</ScopeCard>
|
||||
|
||||
<ScopeCard variant="shared" count={files.size}>
|
||||
<WhoWhenSection
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
bind:dateIso={dateIso}
|
||||
initialSenderName={initialSenderName}
|
||||
hideDate={mode === 'edit'}
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
bind:archiveBox={archiveBox}
|
||||
bind:archiveFolder={archiveFolder}
|
||||
hideTitle
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
</ScopeCard>
|
||||
{:else}
|
||||
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
{#if mode === 'edit' && activeFile}
|
||||
<div data-testid="readonly-title">
|
||||
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_title()}
|
||||
</span>
|
||||
<p class="font-serif text-base text-ink">{activeFile.title}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_title()} <span class="text-danger">*</span>
|
||||
</span>
|
||||
{#if activeFile}
|
||||
<input
|
||||
type="text"
|
||||
value={activeFile.title}
|
||||
oninput={(e) =>
|
||||
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
|
||||
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"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
placeholder="—"
|
||||
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<WhoWhenSection
|
||||
bind:senderId={senderId}
|
||||
bind:selectedReceivers={selectedReceivers}
|
||||
bind:dateIso={dateIso}
|
||||
initialSenderName={initialSenderName}
|
||||
hideDate={mode === 'edit'}
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
bind:archiveBox={archiveBox}
|
||||
bind:archiveFolder={archiveFolder}
|
||||
hideTitle
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if partialSaved}
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="bulk-edit-partial-failure"
|
||||
class="rounded-sm border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger"
|
||||
>
|
||||
<p class="font-medium">
|
||||
{m.bulk_edit_save_partial({
|
||||
done: partialSaved.done,
|
||||
total: partialSaved.total
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={retrySave}
|
||||
class="mt-2 inline-flex items-center bg-primary px-4 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{m.bulk_edit_retry()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action bar: always visible at bottom of right panel -->
|
||||
<UploadSaveBar
|
||||
fileCount={files.size}
|
||||
chunkProgress={chunkProgress}
|
||||
onSave={save}
|
||||
onDiscard={handleDiscard}
|
||||
disabled={saving}
|
||||
editMode={mode === 'edit'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,543 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { goto } from '$app/navigation';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeFile(name: string): File {
|
||||
return new File(['content'], name, { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
async function addFilesViaInput(container: HTMLElement, files: File[]): Promise<void> {
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
if (!input) throw new Error('No file input found — is BulkDropZone visible?');
|
||||
await userEvent.upload(input, files);
|
||||
}
|
||||
|
||||
describe('BulkDocumentEditLayout', () => {
|
||||
it('N=0: shows BulkDropZone', async () => {
|
||||
render(BulkDocumentEditLayout, {});
|
||||
await expect.element(page.getByTestId('bulk-drop-zone')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('N=1: file-switcher-strip and per-file scope card are absent', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
|
||||
expect(container.querySelector('[data-variant="per-file"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('N=5: file-switcher-strip and per-file scope card are both present', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [
|
||||
makeFile('a.pdf'),
|
||||
makeFile('b.pdf'),
|
||||
makeFile('c.pdf'),
|
||||
makeFile('d.pdf'),
|
||||
makeFile('e.pdf')
|
||||
]);
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-variant="per-file"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('removing middle file preserves order of remaining files', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [
|
||||
makeFile('file0.pdf'),
|
||||
makeFile('file1.pdf'),
|
||||
makeFile('file2.pdf')
|
||||
]);
|
||||
|
||||
// Remove the chip for file1 via its remove button (identified by data-remove-id)
|
||||
const removeButtons = container.querySelectorAll<HTMLButtonElement>(
|
||||
'[data-testid="file-switcher-strip"] button[data-remove-id]'
|
||||
);
|
||||
expect(removeButtons.length).toBe(3);
|
||||
removeButtons[1].click(); // remove file1
|
||||
|
||||
// Wait for Svelte to flush the DOM update
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const chips = container.querySelectorAll(
|
||||
'[data-testid="file-switcher-strip"] [data-chip-id]'
|
||||
);
|
||||
expect(chips.length).toBe(2);
|
||||
expect(chips[0].textContent?.trim()).toContain('file0');
|
||||
expect(chips[1].textContent?.trim()).toContain('file2');
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('save calls fetch twice for 12 files (2 chunks of 10)', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ created: [], updated: [], errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
const files = Array.from({ length: 12 }, (_, i) => makeFile(`f${i}.pdf`));
|
||||
await addFilesViaInput(container, files);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(saveBtn).not.toBeNull();
|
||||
saveBtn.click();
|
||||
|
||||
// Wait for async save to complete
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('save marks file as error when server returns non-ok response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('f0.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('save() includes tagNames in metadata payload', async () => {
|
||||
let capturedFormData: FormData | undefined;
|
||||
const mockFetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
capturedFormData = init?.body as FormData;
|
||||
return { ok: true, json: async () => ({ created: [], updated: [], errors: [] }) };
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
|
||||
expect(capturedFormData).toBeDefined();
|
||||
const metadataBlob = capturedFormData!.get('metadata') as Blob;
|
||||
const metadataJson = JSON.parse(await metadataBlob.text());
|
||||
expect(metadataJson).toHaveProperty('tagNames');
|
||||
});
|
||||
|
||||
it('save() navigates to /documents when all chunks succeed', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ created: [], updated: [], errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('doc.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('save() does not navigate when chunk returns non-ok response', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('f0.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save marks only the file whose filename matches the backend error, not adjacent files', async () => {
|
||||
// backend returns error keyed to b.pdf — only b.pdf chip should get data-status="error"
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||
expect(errorChips.length).toBe(1);
|
||||
expect(errorChips[0].textContent).toContain('b');
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('save() marks only the failed file when server returns HTTP 200 with a partial errors array', async () => {
|
||||
// Backend can return 200 OK while reporting individual file failures
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
created: [{ id: '1' }],
|
||||
updated: [],
|
||||
errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }]
|
||||
})
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||
expect(errorChips.length).toBe(1);
|
||||
expect(errorChips[0].textContent).toContain('b');
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
// Navigation should be suppressed because hadErrors is true
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save() marks all chunk files as errored when fetch throws a network error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||
expect(errorChips.length).toBe(2);
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save() does not call fetch a second time when already saving', async () => {
|
||||
let resolveFirst: (() => void) | undefined;
|
||||
const mockFetch = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFirst = () =>
|
||||
resolve({
|
||||
ok: true,
|
||||
json: async () => ({ created: [], updated: [], errors: [] })
|
||||
} as Response);
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click(); // first click — fetch is in-flight
|
||||
saveBtn.click(); // second click — should be a no-op
|
||||
|
||||
resolveFirst?.();
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('discard-all resets to N=0 state and shows drop zone', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
// Confirm N=2 state — switcher is visible
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
|
||||
|
||||
// Click the topbar discard-all button (only visible in isMulti state)
|
||||
const discardBtn = container.querySelector(
|
||||
'button[data-testid="discard-all-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(discardBtn).not.toBeNull();
|
||||
discardBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mode="edit" ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('BulkDocumentEditLayout — mode="edit" discard', () => {
|
||||
it('discard in edit mode clears the selection store and navigates back to /documents', async () => {
|
||||
const { bulkSelectionStore } = await import('$lib/stores/bulkSelection.svelte');
|
||||
bulkSelectionStore.setAll(['doc-1']);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [
|
||||
{ id: 'doc-1', title: 'Brief 1', pdfUrl: '/api/documents/doc-1/file' },
|
||||
{ id: 'doc-2', title: 'Brief 2', pdfUrl: '/api/documents/doc-2/file' }
|
||||
]
|
||||
});
|
||||
|
||||
const discardBtn = container.querySelector(
|
||||
'button[data-testid="discard-all-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(discardBtn).not.toBeNull();
|
||||
discardBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 1000 });
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BulkDocumentEditLayout — mode="edit"', () => {
|
||||
const editEntry = (i: number) => ({
|
||||
id: `doc-${i}`,
|
||||
title: `Brief ${i}`,
|
||||
pdfUrl: `/api/documents/doc-${i}/file`
|
||||
});
|
||||
|
||||
it('does not render the BulkDropZone in edit mode', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the onboarding callout with role=note in edit mode', async () => {
|
||||
render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
const callout = page.getByTestId('bulk-edit-callout');
|
||||
await expect.element(callout).toBeInTheDocument();
|
||||
await expect.element(callout).toHaveAttribute('role', 'note');
|
||||
});
|
||||
|
||||
it('renders read-only title display (no input) in edit mode', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull();
|
||||
// Per-file ScopeCard absent at N=1 — title rendered in the single card
|
||||
const titleInput = container.querySelector('input[type="text"][value="Brief 1"]');
|
||||
expect(titleInput).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the date field via WhoWhenSection hideDate prop', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows additive badge next to tags label', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows replace badges next to sender and archive fields', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
|
||||
// sender + archiveBox + archiveFolder = 3
|
||||
expect(replaceBadges.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => {
|
||||
// Elicit C1 fix — upload-flavoured "Mehrere Dokumente hochladen" /
|
||||
// "werden erstellt" copy must not appear when mode === 'edit'.
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1), editEntry(2)]
|
||||
});
|
||||
// Topbar title slot
|
||||
const topbar = container.querySelector('span.font-bold.text-ink');
|
||||
expect(topbar?.textContent).toContain('Massenbearbeitung');
|
||||
// Count pill
|
||||
const pill = container.querySelector('span.bg-accent');
|
||||
expect(pill?.textContent).toContain('werden bearbeitet');
|
||||
// Negative: must NOT show upload-flavoured copy
|
||||
expect(topbar?.textContent ?? '').not.toContain('hochladen');
|
||||
expect(pill?.textContent ?? '').not.toContain('werden erstellt');
|
||||
});
|
||||
|
||||
it('shows the archiveBox and archiveFolder bulk-only inputs', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1)]
|
||||
});
|
||||
expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('save calls PATCH /api/documents/bulk in edit mode', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ updated: 2, errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1), editEntry(2)]
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(saveBtn).not.toBeNull();
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/documents/bulk');
|
||||
expect(init.method).toBe('PATCH');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.documentIds).toEqual(['doc-1', 'doc-2']);
|
||||
});
|
||||
|
||||
it('chunks IDs into 500-sized PATCH requests', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ updated: 500, errors: [] })
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: entries
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 });
|
||||
expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500);
|
||||
expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500);
|
||||
expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100);
|
||||
});
|
||||
|
||||
it('stops on chunk failure and shows the partial-failure alert with retry', async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) })
|
||||
.mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) });
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: entries
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]');
|
||||
expect(alert).not.toBeNull();
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
// Should have called twice — chunks 0 and 1 — but not the third.
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(goto)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks per-document error chips when service returns errors[]', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
updated: 1,
|
||||
errors: [{ id: 'doc-2', message: 'Sender not found' }]
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {
|
||||
mode: 'edit',
|
||||
initialEditEntries: [editEntry(1), editEntry(2)]
|
||||
});
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const errorChip = container.querySelector(
|
||||
'[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]'
|
||||
);
|
||||
expect(errorChip).not.toBeNull();
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
80
frontend/src/lib/components/document/BulkDropZone.svelte
Normal file
80
frontend/src/lib/components/document/BulkDropZone.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
onFilesAdded
|
||||
}: {
|
||||
onFilesAdded: (files: File[]) => void;
|
||||
} = $props();
|
||||
|
||||
let isDragging = $state(false);
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="region"
|
||||
aria-label={m.bulk_drop_zone_label()}
|
||||
aria-describedby="bulk-drop-desc"
|
||||
data-testid="bulk-drop-zone"
|
||||
class="flex flex-1 flex-col items-center justify-center p-6"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
}}
|
||||
ondragleave={() => (isDragging = false)}
|
||||
ondrop={(e) => {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||
onFilesAdded(Array.from(e.dataTransfer.files));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class={[
|
||||
'flex w-full max-w-xl flex-col items-center gap-5 rounded-md border-2 border-dashed px-12 py-16 text-center transition-colors',
|
||||
isDragging ? 'border-accent bg-accent/10' : 'border-accent/50 bg-white/[0.04]'
|
||||
].join(' ')}
|
||||
>
|
||||
<!-- Circular mint icon -->
|
||||
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-accent text-primary">
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
points="6 12.5 16 2 26 12.5 24.5714286 14 16.999 6.049 17 30 15 30 14.999 6.051 7.42857143 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Serif title -->
|
||||
<p class="font-serif text-base font-bold text-ink">{m.bulk_drop_hint()}</p>
|
||||
|
||||
<!-- Sub description -->
|
||||
<p id="bulk-drop-desc" class="text-sm leading-relaxed text-ink-2">{m.bulk_drop_desc()}</p>
|
||||
|
||||
<!-- CTA button -->
|
||||
<label
|
||||
class="flex min-h-[44px] cursor-pointer items-center rounded-sm bg-primary px-6 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90"
|
||||
>
|
||||
{m.bulk_select_files()}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="application/pdf"
|
||||
class="sr-only"
|
||||
onchange={(e) => {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
if (files.length > 0) onFilesAdded(files);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Format hint -->
|
||||
<p class="text-xs text-ink-3">{m.bulk_drop_sub()}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import BulkDropZone from './BulkDropZone.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('BulkDropZone', () => {
|
||||
it('file input has multiple attribute', async () => {
|
||||
const { container } = render(BulkDropZone, { onFilesAdded: vi.fn() });
|
||||
const input = container.querySelector('input[type="file"]');
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.hasAttribute('multiple')).toBe(true);
|
||||
});
|
||||
|
||||
it('fires onFilesAdded with selected files when 3 files are picked via input', async () => {
|
||||
const onFilesAdded = vi.fn();
|
||||
render(BulkDropZone, { onFilesAdded });
|
||||
|
||||
const files = [
|
||||
new File(['a'], 'a.pdf', { type: 'application/pdf' }),
|
||||
new File(['b'], 'b.pdf', { type: 'application/pdf' }),
|
||||
new File(['c'], 'c.pdf', { type: 'application/pdf' })
|
||||
];
|
||||
|
||||
const input = page.getByRole('button', { name: /Dateien auswählen/i });
|
||||
await userEvent.upload(input, files);
|
||||
|
||||
expect(onFilesAdded).toHaveBeenCalledOnce();
|
||||
const received: File[] = onFilesAdded.mock.calls[0][0];
|
||||
expect(received).toHaveLength(3);
|
||||
expect(received.map((f) => f.name)).toEqual(['a.pdf', 'b.pdf', 'c.pdf']);
|
||||
});
|
||||
|
||||
it('shows drop hint text', async () => {
|
||||
render(BulkDropZone, { onFilesAdded: vi.fn() });
|
||||
await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
74
frontend/src/lib/components/document/BulkSelectionBar.svelte
Normal file
74
frontend/src/lib/components/document/BulkSelectionBar.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
|
||||
let { canWrite }: { canWrite: boolean } = $props();
|
||||
|
||||
const count = $derived(bulkSelectionStore.size);
|
||||
const visible = $derived(canWrite && count > 0);
|
||||
|
||||
function openBulkEdit() {
|
||||
goto('/documents/bulk-edit');
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
bulkSelectionStore.clear();
|
||||
}
|
||||
|
||||
// Escape clears the selection — keyboard escape hatch when the user has
|
||||
// drilled into a 50-row selection and wants to bail without Tab-ing through
|
||||
// the whole footer (WCAG 2.1.1). Bails when an open dialog, expanded menu,
|
||||
// or popover is in front so we don't steal Esc from NotificationBell,
|
||||
// ConfirmDialog, HelpPopover, etc.
|
||||
function onEscape(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape' || !visible) return;
|
||||
if (e.defaultPrevented) return;
|
||||
const overlay = document.querySelector(
|
||||
'dialog[open], [aria-expanded="true"], [role="menu"]:not([hidden]), [role="dialog"]:not([hidden])'
|
||||
);
|
||||
if (overlay) return;
|
||||
clearAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onEscape} />
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
data-testid="bulk-selection-bar"
|
||||
class="fixed right-0 bottom-0 left-0 z-30 flex items-center justify-between gap-3 border-t border-line bg-surface px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6"
|
||||
>
|
||||
<div class="flex items-baseline gap-3">
|
||||
<span
|
||||
class="font-sans text-sm font-medium text-ink"
|
||||
data-testid="bulk-selection-count"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{count === 1 ? m.bulk_edit_n_selected_one() : m.bulk_edit_n_selected_other({ count })}
|
||||
</span>
|
||||
<span class="hidden font-sans text-xs text-ink-3 sm:inline">
|
||||
{m.bulk_edit_clear_hint_keyboard()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAll}
|
||||
class="inline-flex min-h-[44px] items-center px-4 py-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
data-testid="bulk-clear-all"
|
||||
>
|
||||
{m.bulk_edit_clear_selection()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openBulkEdit}
|
||||
class="inline-flex min-h-[44px] items-center bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
data-testid="bulk-edit-open"
|
||||
>
|
||||
{m.bulk_edit_button()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { goto } from '$app/navigation';
|
||||
import BulkSelectionBar from './BulkSelectionBar.svelte';
|
||||
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.mocked(goto).mockClear();
|
||||
bulkSelectionStore.clear();
|
||||
});
|
||||
|
||||
describe('BulkSelectionBar', () => {
|
||||
it('does not render when canWrite is false', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
render(BulkSelectionBar, { canWrite: false });
|
||||
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when selection is empty', async () => {
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with the current selection count', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect.element(page.getByTestId('bulk-selection-count')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('uses the singular plural form for count=1 (not "1 Dokumente")', async () => {
|
||||
bulkSelectionStore.add('only');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect
|
||||
.element(page.getByTestId('bulk-selection-count'))
|
||||
.toHaveTextContent('1 Dokument ausgewählt');
|
||||
});
|
||||
|
||||
it('uses the plural form for count=2', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect
|
||||
.element(page.getByTestId('bulk-selection-count'))
|
||||
.toHaveTextContent('2 Dokumente ausgewählt');
|
||||
});
|
||||
|
||||
it('clear button empties the store', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await page.getByTestId('bulk-clear-all').click();
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it('Massenbearbeitung navigates to /documents/bulk-edit', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await page.getByTestId('bulk-edit-open').click();
|
||||
expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit');
|
||||
});
|
||||
|
||||
it('selection count region announces via aria-live=polite', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
await expect
|
||||
.element(page.getByTestId('bulk-selection-count'))
|
||||
.toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
it('Escape clears the selection while the bar is visible', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
await expect.poll(() => bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it('Escape is a no-op when the bar is hidden (no selection)', async () => {
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
// Nothing to clear, no error.
|
||||
expect(bulkSelectionStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it('Escape does not clear when an open <dialog> is present (Leonie B6 scope guard)', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
bulkSelectionStore.add('b');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
|
||||
// Simulate a ConfirmDialog being open in front of the bar.
|
||||
const overlay = document.createElement('dialog');
|
||||
overlay.setAttribute('open', '');
|
||||
document.body.appendChild(overlay);
|
||||
try {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
// Escape is captured by the dialog, not the bar — selection survives.
|
||||
expect(bulkSelectionStore.size).toBe(2);
|
||||
} finally {
|
||||
overlay.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('Escape does not clear when an aria-expanded popover is present', async () => {
|
||||
bulkSelectionStore.add('a');
|
||||
render(BulkSelectionBar, { canWrite: true });
|
||||
|
||||
const trigger = document.createElement('button');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
document.body.appendChild(trigger);
|
||||
try {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(bulkSelectionStore.size).toBe(1);
|
||||
} finally {
|
||||
trigger.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
|
||||
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
tags = $bindable<Tag[]>([]),
|
||||
currentTitle = $bindable(''),
|
||||
documentLocation = $bindable(''),
|
||||
archiveBox = $bindable(''),
|
||||
archiveFolder = $bindable(''),
|
||||
initialTitle = '',
|
||||
initialDocumentLocation = '',
|
||||
initialArchiveBox = '',
|
||||
initialArchiveFolder = '',
|
||||
initialSummary = '',
|
||||
titleRequired = false,
|
||||
suggestedTitle = '',
|
||||
hideTitle = false
|
||||
hideTitle = false,
|
||||
editMode = false
|
||||
}: {
|
||||
tags?: Tag[];
|
||||
currentTitle?: string;
|
||||
documentLocation?: string;
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
initialTitle?: string;
|
||||
initialDocumentLocation?: string;
|
||||
initialArchiveBox?: string;
|
||||
initialArchiveFolder?: string;
|
||||
initialSummary?: string;
|
||||
titleRequired?: boolean;
|
||||
suggestedTitle?: string;
|
||||
hideTitle?: boolean;
|
||||
editMode?: boolean;
|
||||
} = $props();
|
||||
|
||||
// Seed bindables from initial-* props once at mount and only when the parent
|
||||
// hasn't already supplied a non-empty value through the binding. onMount runs
|
||||
// exactly once per instance, so this never stomps a parent-driven update on a
|
||||
// later prop change. Required by the single-doc edit flow which seeds from
|
||||
// the document; bulk-edit consumers leave the initial-* unset and bind their
|
||||
// own state.
|
||||
let titleDirty = $state(false);
|
||||
currentTitle = untrack(() => initialTitle);
|
||||
onMount(() => {
|
||||
if (!currentTitle && initialTitle) currentTitle = initialTitle;
|
||||
if (!archiveBox && initialArchiveBox) archiveBox = initialArchiveBox;
|
||||
if (!archiveFolder && initialArchiveFolder) archiveFolder = initialArchiveFolder;
|
||||
});
|
||||
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
|
||||
</script>
|
||||
|
||||
@@ -67,40 +88,61 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
|
||||
|
||||
<!-- Schlagworte (optional) -->
|
||||
<div>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_tags()}
|
||||
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
|
||||
</p>
|
||||
<TagInput bind:tags={tags} />
|
||||
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
|
||||
</div>
|
||||
|
||||
<!-- Inhalt (optional) -->
|
||||
<div>
|
||||
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_content()}</label
|
||||
>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
rows="5"
|
||||
placeholder={m.form_placeholder_content()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{initialSummary}</textarea
|
||||
>
|
||||
</div>
|
||||
{#if !editMode}
|
||||
<!-- Inhalt (optional) — not bulk-editable. -->
|
||||
<div>
|
||||
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_content()}</label
|
||||
>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
rows="5"
|
||||
placeholder={m.form_placeholder_content()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{initialSummary}</textarea
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Aufbewahrungsort (optional) -->
|
||||
<div>
|
||||
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_archive_location()}</label
|
||||
>
|
||||
<!-- Karton -->
|
||||
<div data-testid="description-archive-box">
|
||||
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_archive_box()}
|
||||
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
||||
</label>
|
||||
<input
|
||||
id="documentLocation"
|
||||
id="archiveBox"
|
||||
type="text"
|
||||
name="documentLocation"
|
||||
value={initialDocumentLocation}
|
||||
placeholder={m.form_placeholder_archive_location()}
|
||||
name="archiveBox"
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<!-- Mappe -->
|
||||
<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()}
|
||||
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import DescriptionSection from './DescriptionSection.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fence)', () => {
|
||||
it('pre-fills the title input from initialTitle when currentTitle is empty', async () => {
|
||||
render(DescriptionSection, { initialTitle: 'Brief an Anna' });
|
||||
const titleInput = document.querySelector('input#title') as HTMLInputElement;
|
||||
expect(titleInput).not.toBeNull();
|
||||
expect(titleInput.value).toBe('Brief an Anna');
|
||||
});
|
||||
|
||||
it('does not stomp a parent-bound currentTitle that is already non-empty', async () => {
|
||||
render(DescriptionSection, {
|
||||
currentTitle: 'Parent Title',
|
||||
initialTitle: 'Should Not Win'
|
||||
});
|
||||
const titleInput = document.querySelector('input#title') as HTMLInputElement;
|
||||
expect(titleInput.value).toBe('Parent Title');
|
||||
});
|
||||
|
||||
it('always renders archiveBox + archiveFolder fields regardless of editMode', async () => {
|
||||
render(DescriptionSection, { editMode: false });
|
||||
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the editMode-only archiveBox + archiveFolder fields when editMode=true', async () => {
|
||||
render(DescriptionSection, { editMode: true, hideTitle: true });
|
||||
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('pre-fills archiveBox from initialArchiveBox when archiveBox is empty', async () => {
|
||||
render(DescriptionSection, { initialArchiveBox: 'K-03', hideTitle: true });
|
||||
const input = document.querySelector('input#archiveBox') as HTMLInputElement;
|
||||
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:currentTitle={currentTitle}
|
||||
initialTitle={doc.title ?? ''}
|
||||
initialDocumentLocation={doc.documentLocation ?? ''}
|
||||
initialArchiveBox={doc.archiveBox ?? ''}
|
||||
initialArchiveFolder={doc.archiveFolder ?? ''}
|
||||
initialSummary={doc.summary ?? ''}
|
||||
titleRequired={true}
|
||||
/>
|
||||
|
||||
16
frontend/src/lib/components/document/FieldLabelBadge.svelte
Normal file
16
frontend/src/lib/components/document/FieldLabelBadge.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { variant }: { variant: 'additive' | 'replace' } = $props();
|
||||
|
||||
const text = $derived(
|
||||
variant === 'additive' ? m.bulk_edit_badge_additive() : m.bulk_edit_badge_replace()
|
||||
);
|
||||
</script>
|
||||
|
||||
<span
|
||||
data-testid="field-label-badge-{variant}"
|
||||
class="ml-2 inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[11px] font-medium tracking-wide text-ink-2"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('FieldLabelBadge', () => {
|
||||
it('renders the additive variant text', async () => {
|
||||
render(FieldLabelBadge, { variant: 'additive' });
|
||||
await expect.element(page.getByTestId('field-label-badge-additive')).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByTestId('field-label-badge-additive'))
|
||||
.toHaveTextContent('+ wird hinzugefügt');
|
||||
});
|
||||
|
||||
it('renders the replace variant text', async () => {
|
||||
render(FieldLabelBadge, { variant: 'replace' });
|
||||
await expect
|
||||
.element(page.getByTestId('field-label-badge-replace'))
|
||||
.toHaveTextContent('wird ersetzt');
|
||||
});
|
||||
|
||||
it('uses the design-system text-ink-2 token (not raw Tailwind palette)', async () => {
|
||||
render(FieldLabelBadge, { variant: 'replace' });
|
||||
await expect.element(page.getByTestId('field-label-badge-replace')).toHaveClass(/text-ink-2/);
|
||||
});
|
||||
});
|
||||
154
frontend/src/lib/components/document/FileSwitcherStrip.svelte
Normal file
154
frontend/src/lib/components/document/FileSwitcherStrip.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
export interface FileEntry {
|
||||
id: string;
|
||||
/** Present in upload mode only. Edit mode entries reference an existing
|
||||
* document by `documentId` and have no local file blob. */
|
||||
file?: File;
|
||||
/** Present in edit mode only — the server-side document UUID being edited. */
|
||||
documentId?: string;
|
||||
title: string;
|
||||
status: 'idle' | 'error';
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
let {
|
||||
files,
|
||||
activeId,
|
||||
onSelect,
|
||||
onRemove
|
||||
}: {
|
||||
files: FileEntry[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
} = $props();
|
||||
|
||||
let trackEl = $state<HTMLDivElement | null>(null);
|
||||
let listEl = $state<HTMLUListElement | null>(null);
|
||||
|
||||
const activeAnnouncement = $derived(files.find((f) => f.id === activeId)?.title ?? '');
|
||||
|
||||
function scrollPrev() {
|
||||
trackEl?.scrollBy({ left: -120, behavior: 'smooth' });
|
||||
}
|
||||
function scrollNext() {
|
||||
trackEl?.scrollBy({ left: 120, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
async function handleRemove(entry: FileEntry, index: number) {
|
||||
const targetId = index > 0 ? files[index - 1].id : (files[index + 1]?.id ?? null);
|
||||
onRemove(entry.id);
|
||||
if (targetId) {
|
||||
await tick();
|
||||
(listEl?.querySelector<HTMLElement>(`[data-chip-id="${targetId}"]`) ?? null)?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!listEl) return;
|
||||
const node = listEl;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const buttons = Array.from(node.querySelectorAll<HTMLElement>('[data-chip-id]'));
|
||||
if (buttons.length === 0) return;
|
||||
|
||||
const focusedIndex = buttons.indexOf(document.activeElement as HTMLElement);
|
||||
if (focusedIndex === -1) return;
|
||||
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
const nextIndex = (focusedIndex + 1) % buttons.length;
|
||||
buttons[nextIndex].focus();
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const prevIndex = (focusedIndex - 1 + buttons.length) % buttons.length;
|
||||
buttons[prevIndex].focus();
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener('keydown', handleKeyDown);
|
||||
return () => node.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{activeAnnouncement}</div>
|
||||
<div
|
||||
data-testid="file-switcher-strip"
|
||||
class="flex h-11 shrink-0 items-center gap-1 border-t border-line bg-pdf-ctrl px-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.bulk_switcher_prev()}
|
||||
onclick={scrollPrev}
|
||||
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>‹</button
|
||||
>
|
||||
|
||||
<!-- Gradient fade overlays signal hidden overflow to pointer-only users -->
|
||||
<div class="relative flex flex-1 overflow-hidden">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-pdf-ctrl to-transparent"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-pdf-ctrl to-transparent"
|
||||
></div>
|
||||
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
|
||||
<ul bind:this={listEl} role="list" class="flex flex-row gap-1 py-1">
|
||||
{#each files as entry, i (entry.id)}
|
||||
<li role="listitem" class="inline-flex shrink-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
aria-current={entry.id === activeId ? 'true' : undefined}
|
||||
data-status={entry.status}
|
||||
data-chip-id={entry.id}
|
||||
onclick={() => onSelect(entry.id)}
|
||||
class={[
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded-[2px] px-1.5 py-0.5 text-xs font-bold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
|
||||
entry.id === activeId
|
||||
? 'bg-accent text-primary'
|
||||
: 'bg-black/[0.06] text-ink-2 hover:bg-black/10',
|
||||
entry.status === 'error'
|
||||
? '!border !border-dashed !border-red-400 !bg-red-50/80 !text-red-700'
|
||||
: ''
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
class={[
|
||||
'rounded-[2px] px-0.5 text-[11px] font-extrabold opacity-85',
|
||||
entry.id === activeId ? 'bg-black/20' : 'bg-black/10'
|
||||
].join(' ')}
|
||||
>{i + 1}</span
|
||||
>
|
||||
<span class="max-w-[8rem] truncate" title={entry.title}>{entry.title}</span>
|
||||
{#if entry.status === 'error'}
|
||||
<span class="sr-only">{m.bulk_file_error_chip_label()}</span>
|
||||
<span aria-hidden="true" class="ml-0.5 font-extrabold text-red-600">!</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.bulk_remove_file()}
|
||||
data-remove-id={entry.id}
|
||||
onclick={() => handleRemove(entry, i)}
|
||||
class="ml-0.5 flex h-[44px] w-[44px] items-center justify-center text-base text-ink-3 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.bulk_switcher_next()}
|
||||
onclick={scrollNext}
|
||||
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>›</button
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeFiles(n: number): FileEntry[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `id-${i}`,
|
||||
file: new File([''], `file${i}.pdf`),
|
||||
title: `File ${i}`,
|
||||
status: 'idle' as const,
|
||||
previewUrl: ''
|
||||
}));
|
||||
}
|
||||
|
||||
describe('FileSwitcherStrip', () => {
|
||||
it('renders N chips for N files', async () => {
|
||||
const files = makeFiles(4);
|
||||
render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[0].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const chips = page.getByRole('listitem');
|
||||
await expect.element(chips.nth(0)).toBeInTheDocument();
|
||||
await expect.element(chips.nth(3)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('active chip has aria-current="true"', async () => {
|
||||
const files = makeFiles(3);
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[1].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const activeBtn = container.querySelector('[aria-current="true"]');
|
||||
expect(activeBtn).not.toBeNull();
|
||||
expect(activeBtn?.textContent).toContain('File 1');
|
||||
});
|
||||
|
||||
it('clicking a chip fires onSelect with its id', async () => {
|
||||
const files = makeFiles(3);
|
||||
const onSelect = vi.fn();
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[0].id,
|
||||
onSelect,
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const chip = container.querySelector('[data-chip-id="id-2"]') as HTMLElement;
|
||||
expect(chip).not.toBeNull();
|
||||
chip.click();
|
||||
expect(onSelect).toHaveBeenCalledWith('id-2');
|
||||
});
|
||||
|
||||
it('error chip has aria-label containing warning indicator', async () => {
|
||||
const files: FileEntry[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
file: new File([''], 'bad.pdf'),
|
||||
title: 'Bad file',
|
||||
status: 'error',
|
||||
previewUrl: ''
|
||||
}
|
||||
];
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: 'e1',
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const errBtn = container.querySelector('[data-status="error"]');
|
||||
expect(errBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('error chip contains a screen-reader-only error label', async () => {
|
||||
const files: FileEntry[] = [
|
||||
{
|
||||
id: 'e1',
|
||||
file: new File([''], 'bad.pdf'),
|
||||
title: 'Bad file',
|
||||
status: 'error',
|
||||
previewUrl: ''
|
||||
}
|
||||
];
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: 'e1',
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const errBtn = container.querySelector('[data-status="error"]');
|
||||
const srOnly = errBtn?.querySelector('.sr-only');
|
||||
expect(srOnly).not.toBeNull();
|
||||
});
|
||||
|
||||
it('focus moves to the previous chip after the middle chip is removed', async () => {
|
||||
const files = makeFiles(3); // id-0, id-1, id-2
|
||||
const onRemove = vi.fn();
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[1].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove
|
||||
});
|
||||
|
||||
const removeBtn = container.querySelector('[data-remove-id="id-1"]') as HTMLButtonElement;
|
||||
expect(removeBtn).not.toBeNull();
|
||||
removeBtn.click();
|
||||
expect(onRemove).toHaveBeenCalledWith('id-1');
|
||||
|
||||
// After removal, focus should be on the chip for id-0 (the previous chip)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const prevChip = container.querySelector('[data-chip-id="id-0"]') as HTMLElement | null;
|
||||
expect(prevChip).not.toBeNull();
|
||||
expect(document.activeElement).toBe(prevChip);
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('ArrowRight moves focus to next chip without leaving strip', async () => {
|
||||
const files = makeFiles(3);
|
||||
const { container } = render(FileSwitcherStrip, {
|
||||
files,
|
||||
activeId: files[0].id,
|
||||
onSelect: vi.fn(),
|
||||
onRemove: vi.fn()
|
||||
});
|
||||
const firstBtn = container.querySelectorAll('[data-chip-id]')[0] as HTMLElement;
|
||||
firstBtn.focus();
|
||||
await userEvent.keyboard('{ArrowRight}');
|
||||
const focused = document.activeElement;
|
||||
expect(focused).not.toBe(firstBtn);
|
||||
// The new focused element should still be inside the strip
|
||||
const strip = container.querySelector('[data-testid="file-switcher-strip"]');
|
||||
expect(strip?.contains(focused)).toBe(true);
|
||||
});
|
||||
});
|
||||
40
frontend/src/lib/components/document/ScopeCard.svelte
Normal file
40
frontend/src/lib/components/document/ScopeCard.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
variant,
|
||||
count = 0,
|
||||
children
|
||||
}: {
|
||||
variant: 'per-file' | 'shared';
|
||||
count?: number;
|
||||
children?: import('svelte').Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="scope-card"
|
||||
data-variant={variant}
|
||||
class="mb-3 rounded-sm border p-4
|
||||
{variant === 'per-file'
|
||||
? 'border-accent bg-accent-bg'
|
||||
: 'border-line bg-surface'}"
|
||||
>
|
||||
{#if variant === 'shared'}
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.bulk_scope_shared_label({ count })}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-xs font-bold text-primary"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-3 text-xs font-bold tracking-widest text-primary uppercase">
|
||||
{m.bulk_scope_per_file_label()}
|
||||
</p>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import ScopeCard from './ScopeCard.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ScopeCard', () => {
|
||||
it('per-file variant has accent background class', async () => {
|
||||
const { container } = render(ScopeCard, { variant: 'per-file', count: 1 });
|
||||
const card = container.querySelector('[data-testid="scope-card"]');
|
||||
expect(card?.className).toMatch(/bg-accent-bg/);
|
||||
});
|
||||
|
||||
it('shared variant does not have accent background', async () => {
|
||||
const { container } = render(ScopeCard, { variant: 'shared', count: 3 });
|
||||
const card = container.querySelector('[data-testid="scope-card"]');
|
||||
expect(card?.className).not.toMatch(/bg-accent-bg/);
|
||||
});
|
||||
|
||||
it('shared variant renders count badge with file count', async () => {
|
||||
render(ScopeCard, { variant: 'shared', count: 5 });
|
||||
await expect.element(page.getByText('5', { exact: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('per-file variant renders slot content', async () => {
|
||||
// ScopeCard is a container — verify it renders children
|
||||
render(ScopeCard, { variant: 'per-file', count: 1 });
|
||||
const card = await page.getByTestId('scope-card');
|
||||
await expect.element(card).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
69
frontend/src/lib/components/document/UploadSaveBar.svelte
Normal file
69
frontend/src/lib/components/document/UploadSaveBar.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let {
|
||||
fileCount,
|
||||
chunkProgress,
|
||||
onSave,
|
||||
onDiscard,
|
||||
disabled = false,
|
||||
editMode = false
|
||||
}: {
|
||||
fileCount: number;
|
||||
chunkProgress?: { done: number; total: number };
|
||||
onSave: () => void;
|
||||
onDiscard: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
editMode?: boolean;
|
||||
} = $props();
|
||||
|
||||
const saveCta = $derived.by(() => {
|
||||
if (editMode) return m.bulk_edit_save_button();
|
||||
return fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shrink-0 border-t border-line bg-surface px-4 py-3">
|
||||
{#if chunkProgress}
|
||||
<progress
|
||||
value={chunkProgress.done}
|
||||
max={chunkProgress.total}
|
||||
aria-valuenow={chunkProgress.done}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={chunkProgress.total}
|
||||
aria-label={editMode
|
||||
? m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })
|
||||
: m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })}
|
||||
class="[&::-webkit-progress-bar]:bg-brand-sand mb-2 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
|
||||
></progress>
|
||||
{#if editMode && chunkProgress.total > 1}
|
||||
<!-- Visible progress text for sighted users on multi-chunk PATCH
|
||||
(Elicit S3 — the unitless bar isn't enough for a 30-second op). -->
|
||||
<p
|
||||
class="mb-2 font-sans text-xs text-ink-2"
|
||||
aria-live="polite"
|
||||
data-testid="bulk-edit-chunk-progress-text"
|
||||
>
|
||||
{m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onDiscard}
|
||||
class="flex min-h-[44px] items-center px-2 text-sm text-red-600/70 hover:text-red-700"
|
||||
>
|
||||
{m.bulk_discard_all()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bulk-save-btn"
|
||||
disabled={fileCount === 0 || disabled}
|
||||
onclick={onSave}
|
||||
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{saveCta}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import UploadSaveBar from './UploadSaveBar.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('UploadSaveBar', () => {
|
||||
it('shows plural label for multiple files', async () => {
|
||||
render(UploadSaveBar, { fileCount: 5, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||
// "5 speichern →" or similar plural form
|
||||
await expect.element(page.getByText(/5/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows singular label for one file', async () => {
|
||||
render(UploadSaveBar, { fileCount: 1, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||
// "Speichern →" singular form
|
||||
await expect.element(page.getByText(/Speichern/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('progress bar is visible when chunkProgress is provided', async () => {
|
||||
const { container } = render(UploadSaveBar, {
|
||||
fileCount: 3,
|
||||
chunkProgress: { done: 1, total: 3 },
|
||||
onSave: vi.fn(),
|
||||
onDiscard: vi.fn()
|
||||
});
|
||||
const progress = container.querySelector('progress');
|
||||
expect(progress).not.toBeNull();
|
||||
expect(progress?.getAttribute('value')).toBe('1');
|
||||
expect(progress?.getAttribute('max')).toBe('3');
|
||||
});
|
||||
|
||||
it('progress bar is not rendered when no chunkProgress', async () => {
|
||||
const { container } = render(UploadSaveBar, {
|
||||
fileCount: 2,
|
||||
onSave: vi.fn(),
|
||||
onDiscard: vi.fn()
|
||||
});
|
||||
const progress = container.querySelector('progress');
|
||||
expect(progress).toBeNull();
|
||||
});
|
||||
|
||||
it('discard link is rendered', async () => {
|
||||
render(UploadSaveBar, { fileCount: 2, onSave: vi.fn(), onDiscard: vi.fn() });
|
||||
await expect.element(page.getByText(/verwerfen/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
|
||||
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
@@ -16,7 +17,9 @@ let {
|
||||
initialLocation = '',
|
||||
initialSenderName = '',
|
||||
suggestedDateIso = '',
|
||||
suggestedSenderName = ''
|
||||
suggestedSenderName = '',
|
||||
hideDate = false,
|
||||
editMode = false
|
||||
}: {
|
||||
senderId?: string;
|
||||
selectedReceivers?: Person[];
|
||||
@@ -26,12 +29,24 @@ let {
|
||||
initialSenderName?: string;
|
||||
suggestedDateIso?: string;
|
||||
suggestedSenderName?: string;
|
||||
hideDate?: boolean;
|
||||
editMode?: boolean;
|
||||
} = $props();
|
||||
|
||||
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso)));
|
||||
dateIso = untrack(() => initialDateIso);
|
||||
// dateDisplay seeds from the bindable's value or initialDateIso once at mount
|
||||
// and is then user-driven. onMount runs exactly once, so this never stomps
|
||||
// the parent's dateIso on a later prop change.
|
||||
let dateDisplay = $state('');
|
||||
let dateDirty = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
const seed = dateIso || initialDateIso;
|
||||
if (seed) {
|
||||
dateDisplay = isoToGerman(seed);
|
||||
if (!dateIso) dateIso = seed;
|
||||
}
|
||||
});
|
||||
|
||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||
|
||||
function handleDateInput(e: Event) {
|
||||
@@ -56,62 +71,72 @@ $effect(() => {
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<!-- Datum (required — row 1, col 1) -->
|
||||
<div>
|
||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_date()}*</label
|
||||
>
|
||||
<input
|
||||
id="documentDate"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
autofocus={!initialDateIso}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm
|
||||
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !hideDate}
|
||||
<!-- Datum (required — row 1, col 1) -->
|
||||
<div data-testid="who-when-date">
|
||||
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_date()}*</label
|
||||
>
|
||||
<input
|
||||
id="documentDate"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
value={dateDisplay}
|
||||
oninput={handleDateInput}
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||
{dateInvalid
|
||||
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
|
||||
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
{#if dateInvalid}
|
||||
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Absender (required — row 1, col 2) -->
|
||||
<!-- Absender (required in upload mode — row 1, col 2) -->
|
||||
<div>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label={m.form_label_sender()}
|
||||
required={true}
|
||||
required={!editMode}
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
suggestedName={suggestedSenderName}
|
||||
autofocus={!!initialDateIso}
|
||||
badge={editMode ? 'replace' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empfänger (optional — row 2, col 1) -->
|
||||
<div>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_receivers()}</p>
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">
|
||||
{m.form_label_receivers()}
|
||||
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
|
||||
</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedReceivers} />
|
||||
</div>
|
||||
|
||||
<!-- Ort (optional — row 2, col 2) -->
|
||||
<div>
|
||||
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_location()}</label
|
||||
>
|
||||
<input
|
||||
id="location"
|
||||
type="text"
|
||||
name="location"
|
||||
value={initialLocation}
|
||||
placeholder={m.form_placeholder_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"
|
||||
/>
|
||||
</div>
|
||||
{#if !editMode}
|
||||
<!-- Ort (optional — row 2, col 2). Hidden in editMode: meta_location is
|
||||
NOT bulk-editable per the issue spec; the three editable location
|
||||
fields live in DescriptionSection. -->
|
||||
<div>
|
||||
<label for="location" class="mb-1 block text-sm font-medium text-ink-2"
|
||||
>{m.form_label_location()}</label
|
||||
>
|
||||
<input
|
||||
id="location"
|
||||
type="text"
|
||||
name="location"
|
||||
value={initialLocation}
|
||||
placeholder={m.form_placeholder_location()}
|
||||
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import WhoWhenSection from './WhoWhenSection.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', () => {
|
||||
it('pre-fills the date input from initialDateIso when the bindable is empty', async () => {
|
||||
render(WhoWhenSection, { initialDateIso: '2024-03-15' });
|
||||
// isoToGerman('2024-03-15') → '15.03.2024'
|
||||
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
|
||||
expect(dateInput).not.toBeNull();
|
||||
expect(dateInput.value).toBe('15.03.2024');
|
||||
});
|
||||
|
||||
it('does not stomp a parent-bound dateIso that is already non-empty', async () => {
|
||||
// dateIso bindable is '2026-01-01' from the parent; initialDateIso is the
|
||||
// "fallback seed". onMount must not overwrite the already-bound value.
|
||||
render(WhoWhenSection, { dateIso: '2026-01-01', initialDateIso: '1900-01-01' });
|
||||
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
|
||||
expect(dateInput.value).toBe('01.01.2026');
|
||||
});
|
||||
|
||||
it('hides the date field when hideDate=true (bulk-edit mode)', async () => {
|
||||
render(WhoWhenSection, { hideDate: true });
|
||||
await expect.element(page.getByTestId('who-when-date')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the meta_location input only outside editMode', async () => {
|
||||
const { rerender } = render(WhoWhenSection, { editMode: true });
|
||||
expect(document.querySelector('input#location')).toBeNull();
|
||||
await rerender({ editMode: false });
|
||||
expect(document.querySelector('input#location')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('pre-fills the location input from initialLocation', async () => {
|
||||
render(WhoWhenSection, { editMode: false, initialLocation: 'Berlin' });
|
||||
const locationInput = document.querySelector('input#location') as HTMLInputElement;
|
||||
expect(locationInput.value).toBe('Berlin');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user