Compare commits
163 Commits
8f28a99e00
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c641d704a8 | ||
|
|
5b18b87450 | ||
|
|
bfa8b9c147 | ||
|
|
3a94d62c74 | ||
|
|
163e99016a | ||
|
|
d6f3ca5c43 | ||
|
|
108edff8d2 | ||
|
|
3d3fe8d626 | ||
|
|
31e5573eab | ||
|
|
934a00feb3 | ||
|
|
be27489618 | ||
|
|
4e486a31cf | ||
|
|
2c5877ea9e | ||
|
|
cfbe33140c | ||
| e8d1835ae1 | |||
|
|
69ac183fe8 | ||
|
|
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 |
3
backend/api_tests/Transcription.http
Normal file
3
backend/api_tests/Transcription.http
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
### Mark all blocks as reviewed
|
||||||
|
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
|
||||||
|
Authorization: Basic admin admin123
|
||||||
@@ -26,7 +26,16 @@ public enum AuditKind {
|
|||||||
COMMENT_ADDED,
|
COMMENT_ADDED,
|
||||||
|
|
||||||
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
||||||
MENTION_CREATED;
|
MENTION_CREATED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
||||||
|
USER_CREATED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
||||||
|
USER_DELETED,
|
||||||
|
|
||||||
|
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
|
||||||
|
GROUP_MEMBERSHIP_CHANGED;
|
||||||
|
|
||||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
package org.raddatz.familienarchiv.audit;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@@ -197,4 +199,6 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
|||||||
ORDER BY ranked.document_id, ranked.rn
|
ORDER BY ranked.document_id, ranked.rn
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
|
||||||
|
|
||||||
|
Page<AuditLog> findByKindIn(Collection<AuditKind> kinds, Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
package org.raddatz.familienarchiv.audit;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
|
import static org.raddatz.familienarchiv.audit.AuditKind.GROUP_MEMBERSHIP_CHANGED;
|
||||||
|
import static org.raddatz.familienarchiv.audit.AuditKind.USER_CREATED;
|
||||||
|
import static org.raddatz.familienarchiv.audit.AuditKind.USER_DELETED;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuditLogQueryService {
|
public class AuditLogQueryService {
|
||||||
@@ -51,6 +57,11 @@ public class AuditLogQueryService {
|
|||||||
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
return toContributorMap(queryRepository.findRecentContributorsForDocuments(documentIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<AuditLog> findRecentUserManagementEvents(int limit) {
|
||||||
|
PageRequest page = PageRequest.of(0, limit, Sort.by("happenedAt").descending());
|
||||||
|
return queryRepository.findByKindIn(Set.of(USER_CREATED, USER_DELETED, GROUP_MEMBERSHIP_CHANGED), page).getContent();
|
||||||
|
}
|
||||||
|
|
||||||
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
private Map<UUID, List<ActivityActorDTO>> toContributorMap(List<ContributorRow> rows) {
|
||||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
||||||
for (ContributorRow row : rows) {
|
for (ContributorRow row : rows) {
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
||||||
|
boolean existsByKind(AuditKind kind);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
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.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
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.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
@@ -62,6 +75,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
@RequestMapping("/api/documents")
|
@RequestMapping("/api/documents")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@Validated
|
||||||
public class DocumentController {
|
public class DocumentController {
|
||||||
|
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
@@ -187,6 +201,7 @@ public class DocumentController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public QuickUploadResult quickUpload(
|
public QuickUploadResult quickUpload(
|
||||||
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
||||||
|
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
List<Document> created = new ArrayList<>();
|
List<Document> created = new ArrayList<>();
|
||||||
List<Document> updated = new ArrayList<>();
|
List<Document> updated = new ArrayList<>();
|
||||||
@@ -196,14 +211,21 @@ public class DocumentController {
|
|||||||
return new QuickUploadResult(created, updated, errors);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
documentService.validateBatch(files.size(), metadata);
|
||||||
|
|
||||||
UUID actorId = requireUserId(authentication);
|
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())) {
|
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||||
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
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()) {
|
if (result.isNew()) {
|
||||||
created.add(result.document());
|
created.add(result.document());
|
||||||
} else {
|
} 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);
|
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")
|
@GetMapping("/incomplete-count")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public Map<String, Long> getIncompleteCount() {
|
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 = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||||
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
@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 = "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)) {
|
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
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)
|
// 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.
|
// defaults to AND, which matches the frontend default and keeps old clients working.
|
||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
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 ---
|
// --- TRAINING LABELS ---
|
||||||
|
|||||||
@@ -63,27 +63,33 @@ public class PersonController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
validatePersonNames(dto);
|
||||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
|
||||||
}
|
|
||||||
dto.setFirstName(dto.getFirstName().trim());
|
|
||||||
dto.setLastName(dto.getLastName().trim());
|
dto.setLastName(dto.getLastName().trim());
|
||||||
|
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||||
return ResponseEntity.ok(personService.createPerson(dto));
|
return ResponseEntity.ok(personService.createPerson(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
validatePersonNames(dto);
|
||||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
|
||||||
}
|
|
||||||
dto.setFirstName(dto.getFirstName().trim());
|
|
||||||
dto.setLastName(dto.getLastName().trim());
|
dto.setLastName(dto.getLastName().trim());
|
||||||
|
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
||||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validatePersonNames(PersonUpdateDTO dto) {
|
||||||
|
if (dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nachname ist Pflichtfeld");
|
||||||
|
}
|
||||||
|
if (dto.getPersonType() == org.raddatz.familienarchiv.model.PersonType.PERSON
|
||||||
|
&& (dto.getFirstName() == null || dto.getFirstName().isBlank())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vorname ist Pflichtfeld");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/merge")
|
@PostMapping("/{id}/merge")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ public class TranscriptionBlockController {
|
|||||||
return transcriptionService.reviewBlock(documentId, blockId, userId);
|
return transcriptionService.reviewBlock(documentId, blockId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/review-all")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public List<TranscriptionBlock> markAllBlocksReviewed(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
Authentication authentication) {
|
||||||
|
UUID userId = requireUserId(authentication);
|
||||||
|
return transcriptionService.markAllBlocksReviewed(documentId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{blockId}/history")
|
@GetMapping("/{blockId}/history")
|
||||||
@RequirePermission(Permission.READ_ALL)
|
@RequirePermission(Permission.READ_ALL)
|
||||||
public List<TranscriptionBlockVersion> getBlockHistory(
|
public List<TranscriptionBlockVersion> getBlockHistory(
|
||||||
|
|||||||
@@ -78,24 +78,31 @@ public class UserController {
|
|||||||
|
|
||||||
@PostMapping("/users")
|
@PostMapping("/users")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
|
public ResponseEntity<AppUser> createUser(Authentication authentication,
|
||||||
return ResponseEntity.ok(userService.createUserOrUpdate(request));
|
@Valid @RequestBody CreateUserRequest request) {
|
||||||
|
return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/users/{id}")
|
@PutMapping("/users/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
|
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
|
||||||
|
@PathVariable UUID id,
|
||||||
@RequestBody AdminUpdateUserRequest dto) {
|
@RequestBody AdminUpdateUserRequest dto) {
|
||||||
AppUser updated = userService.adminUpdateUser(id, dto);
|
AppUser updated = userService.adminUpdateUser(actorId(authentication), id, dto);
|
||||||
updated.setPassword(null);
|
updated.setPassword(null);
|
||||||
return ResponseEntity.ok(updated);
|
return ResponseEntity.ok(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/users/{id}")
|
@DeleteMapping("/users/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
|
public ResponseEntity<Void> deleteUser(Authentication authentication,
|
||||||
userService.deleteUser(id);
|
@PathVariable UUID id) {
|
||||||
|
userService.deleteUser(actorId(authentication), id);
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UUID actorId(Authentication auth) {
|
||||||
|
return userService.findByEmail(auth.getName()).getId();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -8,9 +9,30 @@ public record DocumentSearchResult(
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
List<DocumentSearchItem> items,
|
List<DocumentSearchItem> items,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@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) {
|
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 LocalDate documentDate;
|
||||||
private String location;
|
private String location;
|
||||||
private String documentLocation;
|
private String documentLocation;
|
||||||
|
private String archiveBox;
|
||||||
|
private String archiveFolder;
|
||||||
private String transcription;
|
private String transcription;
|
||||||
private String summary;
|
private String summary;
|
||||||
private UUID senderId;
|
private UUID senderId;
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonType;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PersonUpdateDTO {
|
public class PersonUpdateDTO {
|
||||||
|
@NotNull
|
||||||
|
private PersonType personType;
|
||||||
@Size(max = 50)
|
@Size(max = 50)
|
||||||
private String title;
|
private String title;
|
||||||
@Size(max = 100)
|
@Size(max = 100)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public enum ErrorCode {
|
|||||||
PERSON_NOT_FOUND,
|
PERSON_NOT_FOUND,
|
||||||
/** A person name alias with the given ID does not exist. 404 */
|
/** A person name alias with the given ID does not exist. 404 */
|
||||||
ALIAS_NOT_FOUND,
|
ALIAS_NOT_FOUND,
|
||||||
|
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||||
|
INVALID_PERSON_TYPE,
|
||||||
|
|
||||||
// --- Documents ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
@@ -109,6 +111,10 @@ public enum ErrorCode {
|
|||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
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 */
|
/** An unexpected server-side error occurred. 500 */
|
||||||
INTERNAL_ERROR,
|
INTERNAL_ERROR,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
SELECT d.id FROM documents d
|
SELECT d.id FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||||
THEN to_tsquery('german', regexp_replace(
|
THEN to_tsquery('simple', regexp_replace(
|
||||||
websearch_to_tsquery('german', :query)::text,
|
websearch_to_tsquery('german', :query)::text,
|
||||||
'''([^'']+)''',
|
'''([^'']+)''',
|
||||||
'''\\1'':*',
|
'''\\1'':*',
|
||||||
@@ -149,7 +149,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
FROM documents d
|
FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||||
THEN to_tsquery('german', regexp_replace(
|
THEN to_tsquery('simple', regexp_replace(
|
||||||
websearch_to_tsquery('german', :query)::text,
|
websearch_to_tsquery('german', :query)::text,
|
||||||
'''([^'']+)''',
|
'''([^'']+)''',
|
||||||
'''\\1'':*',
|
'''\\1'':*',
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
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.DocumentSearchItem;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
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.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -130,6 +135,52 @@ public class DocumentService {
|
|||||||
return new StoreResult(saved, isNew);
|
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
|
@Transactional
|
||||||
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
||||||
String filename = (file != null && !file.isEmpty())
|
String filename = (file != null && !file.isEmpty())
|
||||||
@@ -220,6 +271,8 @@ public class DocumentService {
|
|||||||
doc.setTranscription(dto.getTranscription());
|
doc.setTranscription(dto.getTranscription());
|
||||||
doc.setSummary(dto.getSummary());
|
doc.setSummary(dto.getSummary());
|
||||||
doc.setDocumentLocation(dto.getDocumentLocation());
|
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||||
|
doc.setArchiveBox(dto.getArchiveBox());
|
||||||
|
doc.setArchiveFolder(dto.getArchiveFolder());
|
||||||
|
|
||||||
List<String> tags = new ArrayList<>();
|
List<String> tags = new ArrayList<>();
|
||||||
if (dto.getTags() != null && !dto.getTags().isBlank()) {
|
if (dto.getTags() != null && !dto.getTags().isBlank()) {
|
||||||
@@ -285,20 +338,143 @@ public class DocumentService {
|
|||||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||||
Document doc = documentRepository.findById(docId)
|
Document doc = documentRepository.findById(docId)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + 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) {
|
for (String name : tagNames) {
|
||||||
// Clean the string
|
|
||||||
String cleanName = name.trim();
|
String cleanName = name.trim();
|
||||||
if (cleanName.isEmpty())
|
if (cleanName.isEmpty()) continue;
|
||||||
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);
|
Specification<Document> spec = buildSearchSpec(
|
||||||
return documentRepository.save(doc);
|
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)
|
// 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);
|
boolean hasText = StringUtils.hasText(text);
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
|
|
||||||
@@ -364,27 +540,21 @@ public class DocumentService {
|
|||||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
Specification<Document> spec = buildSearchSpec(
|
||||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
||||||
|
|
||||||
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
|
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
||||||
Specification<Document> spec = Specification.where(textSpec)
|
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||||
.and(isBetween(from, to))
|
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
|
||||||
.and(hasSender(sender))
|
// rank list. Cost scales linearly with match count — acceptable while documents
|
||||||
.and(hasReceiver(receiver))
|
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
|
||||||
.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.
|
|
||||||
if (sort == DocumentSort.RECEIVER) {
|
if (sort == DocumentSort.RECEIVER) {
|
||||||
List<Document> results = documentRepository.findAll(spec);
|
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
|
||||||
return buildResult(sortByFirstReceiver(results, dir), text);
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
if (sort == DocumentSort.SENDER) {
|
if (sort == DocumentSort.SENDER) {
|
||||||
List<Document> results = documentRepository.findAll(spec);
|
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
|
||||||
return buildResult(sortBySender(results, dir), text);
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
// RELEVANCE: default when text present and no explicit sort given
|
// RELEVANCE: default when text present and no explicit sort given
|
||||||
@@ -397,15 +567,26 @@ public class DocumentService {
|
|||||||
.sorted(Comparator.comparingInt(
|
.sorted(Comparator.comparingInt(
|
||||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||||
.toList();
|
.toList();
|
||||||
return buildResult(sorted, text);
|
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
Sort springSort = resolveSort(sort, dir);
|
// Fast path — push sort + paging into the DB and enrich only the returned slice.
|
||||||
List<Document> results = documentRepository.findAll(spec, springSort);
|
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort, dir));
|
||||||
return buildResult(results, text);
|
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);
|
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
||||||
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
||||||
|
|
||||||
@@ -413,14 +594,12 @@ public class DocumentService {
|
|||||||
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
||||||
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(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,
|
doc,
|
||||||
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
||||||
completionByDoc.getOrDefault(doc.getId(), 0),
|
completionByDoc.getOrDefault(doc.getId(), 0),
|
||||||
contributorsByDoc.getOrDefault(doc.getId(), List.of())
|
contributorsByDoc.getOrDefault(doc.getId(), List.of())
|
||||||
)).toList();
|
)).toList();
|
||||||
|
|
||||||
return DocumentSearchResult.of(items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
||||||
|
|||||||
@@ -109,8 +109,12 @@ public class PersonService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person createPerson(PersonUpdateDTO dto) {
|
public Person createPerson(PersonUpdateDTO dto) {
|
||||||
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
||||||
|
}
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = Person.builder()
|
Person person = Person.builder()
|
||||||
|
.personType(dto.getPersonType())
|
||||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||||
.firstName(dto.getFirstName())
|
.firstName(dto.getFirstName())
|
||||||
.lastName(dto.getLastName())
|
.lastName(dto.getLastName())
|
||||||
@@ -136,9 +140,13 @@ public class PersonService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||||
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
||||||
|
}
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = personRepository.findById(id)
|
Person person = personRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||||
|
person.setPersonType(dto.getPersonType());
|
||||||
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
||||||
person.setFirstName(dto.getFirstName());
|
person.setFirstName(dto.getFirstName());
|
||||||
person.setLastName(dto.getLastName());
|
person.setLastName(dto.getLastName());
|
||||||
|
|||||||
@@ -205,6 +205,18 @@ public class TranscriptionService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<TranscriptionBlock> markAllBlocksReviewed(UUID documentId, UUID userId) {
|
||||||
|
List<TranscriptionBlock> blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
|
||||||
|
for (TranscriptionBlock block : blocks) {
|
||||||
|
if (!block.isReviewed()) {
|
||||||
|
block.setReviewed(true);
|
||||||
|
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blockRepository.saveAll(blocks);
|
||||||
|
}
|
||||||
|
|
||||||
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
|
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
|
||||||
getBlock(documentId, blockId);
|
getBlock(documentId, blockId);
|
||||||
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
|
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.service;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
@@ -21,10 +23,13 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.stream.Collectors.toSet;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -33,9 +38,10 @@ public class UserService {
|
|||||||
private final AppUserRepository userRepository;
|
private final AppUserRepository userRepository;
|
||||||
private final UserGroupRepository groupRepository;
|
private final UserGroupRepository groupRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
|
||||||
log.info("Creating or updating user: {}", request.getEmail());
|
log.info("Creating or updating user: {}", request.getEmail());
|
||||||
|
|
||||||
Set<UserGroup> groups = new HashSet<>();
|
Set<UserGroup> groups = new HashSet<>();
|
||||||
@@ -45,10 +51,12 @@ public class UserService {
|
|||||||
|
|
||||||
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
||||||
AppUser user;
|
AppUser user;
|
||||||
|
boolean isNew;
|
||||||
|
|
||||||
if (existingUser.isPresent()) {
|
if (existingUser.isPresent()) {
|
||||||
log.info("User exists, updating: {}", request.getEmail());
|
log.info("User exists, updating: {}", request.getEmail());
|
||||||
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||||
|
isNew = false;
|
||||||
} else {
|
} else {
|
||||||
log.info("Creating new user: {}", request.getEmail());
|
log.info("Creating new user: {}", request.getEmail());
|
||||||
user = AppUser.builder()
|
user = AppUser.builder()
|
||||||
@@ -61,8 +69,42 @@ public class UserService {
|
|||||||
.contact(request.getContact())
|
.contact(request.getContact())
|
||||||
.enabled(true)
|
.enabled(true)
|
||||||
.build();
|
.build();
|
||||||
|
isNew = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppUser saved = userRepository.save(user);
|
||||||
|
if (isNew) {
|
||||||
|
auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null,
|
||||||
|
Map.of("userId", saved.getId().toString(), "email", saved.getEmail()));
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AppUser createUserForBootstrap(CreateUserRequest request) {
|
||||||
|
log.info("Bootstrap user creation (no audit): {}", request.getEmail());
|
||||||
|
|
||||||
|
Set<UserGroup> groups = new HashSet<>();
|
||||||
|
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
||||||
|
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
||||||
|
if (existingUser.isPresent()) {
|
||||||
|
AppUser updated = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||||
|
return userRepository.save(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppUser user = AppUser.builder()
|
||||||
|
.email(request.getEmail())
|
||||||
|
.password(passwordEncoder.encode(request.getInitialPassword()))
|
||||||
|
.groups(groups)
|
||||||
|
.firstName(request.getFirstName())
|
||||||
|
.lastName(request.getLastName())
|
||||||
|
.birthDate(request.getBirthDate())
|
||||||
|
.contact(request.getContact())
|
||||||
|
.enabled(true)
|
||||||
|
.build();
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,10 +136,13 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteUser(UUID userId) {
|
public void deleteUser(UUID actorId, UUID userId) {
|
||||||
AppUser user = userRepository.findById(userId)
|
AppUser user = userRepository.findById(userId)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
||||||
|
String email = user.getEmail();
|
||||||
userRepository.delete(user);
|
userRepository.delete(user);
|
||||||
|
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
|
||||||
|
Map.of("userId", userId.toString(), "email", email));
|
||||||
}
|
}
|
||||||
|
|
||||||
public AppUser getById(UUID id) {
|
public AppUser getById(UUID id) {
|
||||||
@@ -141,7 +186,7 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
|
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
|
||||||
AppUser user = getById(id);
|
AppUser user = getById(id);
|
||||||
|
|
||||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||||
@@ -166,13 +211,27 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.getGroupIds() != null) {
|
if (dto.getGroupIds() != null) {
|
||||||
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
Set<UserGroup> before = new HashSet<>(user.getGroups());
|
||||||
user.setGroups(groups);
|
Set<UserGroup> after = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
||||||
|
user.setGroups(after);
|
||||||
|
groupChangePayload(before, after, id, user.getEmail())
|
||||||
|
.ifPresent(payload -> auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Optional<Map<String, Object>> groupChangePayload(
|
||||||
|
Set<UserGroup> before, Set<UserGroup> after, UUID userId, String email) {
|
||||||
|
Set<UUID> beforeIds = before.stream().map(UserGroup::getId).collect(toSet());
|
||||||
|
Set<UUID> afterIds = after.stream().map(UserGroup::getId).collect(toSet());
|
||||||
|
if (beforeIds.equals(afterIds)) return Optional.empty();
|
||||||
|
List<String> added = after.stream().filter(g -> !beforeIds.contains(g.getId())).map(UserGroup::getName).toList();
|
||||||
|
List<String> removed = before.stream().filter(g -> !afterIds.contains(g.getId())).map(UserGroup::getName).toList();
|
||||||
|
return Optional.of(Map.of("userId", userId.toString(), "email", email,
|
||||||
|
"addedGroups", added, "removedGroups", removed));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
||||||
AppUser user = getById(userId);
|
AppUser user = getById(userId);
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ spring:
|
|||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 50MB
|
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:
|
mail:
|
||||||
host: ${MAIL_HOST:}
|
host: ${MAIL_HOST:}
|
||||||
|
|||||||
@@ -6,12 +6,19 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -47,4 +54,21 @@ class AuditLogQueryServiceTest {
|
|||||||
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
|
||||||
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
|
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKinds() {
|
||||||
|
AuditLog entry = AuditLog.builder().id(UUID.randomUUID()).kind(AuditKind.USER_CREATED).build();
|
||||||
|
when(queryRepository.findByKindIn(anyCollection(), any(Pageable.class)))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(entry)));
|
||||||
|
|
||||||
|
List<AuditLog> result = auditLogQueryService.findRecentUserManagementEvents(5);
|
||||||
|
|
||||||
|
assertThat(result).containsExactly(entry);
|
||||||
|
verify(queryRepository).findByKindIn(
|
||||||
|
argThat((Collection<AuditKind> kinds) ->
|
||||||
|
kinds.contains(AuditKind.USER_CREATED) &&
|
||||||
|
kinds.contains(AuditKind.USER_DELETED) &&
|
||||||
|
kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)),
|
||||||
|
any(Pageable.class));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package org.raddatz.familienarchiv.audit;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||||
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
|
import org.raddatz.familienarchiv.dto.GroupDTO;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.UserGroup;
|
||||||
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static java.util.concurrent.TimeUnit.SECONDS;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.awaitility.Awaitility.await;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class UserManagementAuditIntegrationTest {
|
||||||
|
|
||||||
|
@MockitoBean S3Client s3Client;
|
||||||
|
@Autowired UserService userService;
|
||||||
|
@Autowired AppUserRepository userRepository;
|
||||||
|
@Autowired AuditLogRepository auditLogRepository;
|
||||||
|
@Autowired AuditLogQueryService auditLogQueryService;
|
||||||
|
@Autowired TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clearAuditLog() {
|
||||||
|
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAndDeleteUser_producesOrderedAuditEntries() {
|
||||||
|
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
|
||||||
|
CreateUserRequest adminReq = new CreateUserRequest();
|
||||||
|
adminReq.setEmail("admin@test.example.com");
|
||||||
|
adminReq.setInitialPassword("admin-secret");
|
||||||
|
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(adminReq));
|
||||||
|
UUID actorId = actor.getId();
|
||||||
|
|
||||||
|
// Create the target user — should emit USER_CREATED
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setEmail("audit-test@example.com");
|
||||||
|
req.setInitialPassword("secret");
|
||||||
|
transactionTemplate.execute(status -> {
|
||||||
|
userService.createUserOrUpdate(actorId, req);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
||||||
|
|
||||||
|
// Delete the target user — should emit USER_DELETED
|
||||||
|
AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow();
|
||||||
|
transactionTemplate.execute(status -> {
|
||||||
|
userService.deleteUser(actorId, created.getId());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED));
|
||||||
|
|
||||||
|
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
|
||||||
|
assertThat(events).hasSize(2);
|
||||||
|
assertThat(events.get(0).getKind()).isEqualTo(AuditKind.USER_DELETED);
|
||||||
|
assertThat(events.get(1).getKind()).isEqualTo(AuditKind.USER_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateUserGroups_producesGroupMembershipChangedEvent() {
|
||||||
|
GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL"));
|
||||||
|
GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL"));
|
||||||
|
UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto));
|
||||||
|
UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto));
|
||||||
|
|
||||||
|
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
|
||||||
|
CreateUserRequest actorReq = new CreateUserRequest();
|
||||||
|
actorReq.setEmail("actor-group-test@test.example.com");
|
||||||
|
actorReq.setInitialPassword("secret");
|
||||||
|
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(actorReq));
|
||||||
|
|
||||||
|
// Create target user pre-assigned to gA — emits USER_CREATED
|
||||||
|
CreateUserRequest targetReq = new CreateUserRequest();
|
||||||
|
targetReq.setEmail("target-group-test@test.example.com");
|
||||||
|
targetReq.setInitialPassword("secret");
|
||||||
|
targetReq.setGroupIds(List.of(gA.getId()));
|
||||||
|
transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq));
|
||||||
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
|
||||||
|
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
|
||||||
|
|
||||||
|
AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow();
|
||||||
|
|
||||||
|
// Change groups: Viewers → Editors
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setGroupIds(List.of(gB.getId()));
|
||||||
|
transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto));
|
||||||
|
|
||||||
|
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED));
|
||||||
|
|
||||||
|
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
|
||||||
|
assertThat(events).hasSize(1);
|
||||||
|
AuditLog event = events.get(0);
|
||||||
|
assertThat(event.getKind()).isEqualTo(AuditKind.GROUP_MEMBERSHIP_CHANGED);
|
||||||
|
assertThat(event.getPayload()).containsEntry("email", "target-group-test@test.example.com");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> added = (List<String>) event.getPayload().get("addedGroups");
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> removed = (List<String>) event.getPayload().get("removedGroups");
|
||||||
|
assertThat(added).containsExactlyInAnyOrder("Editors");
|
||||||
|
assertThat(removed).containsExactlyInAnyOrder("Viewers");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,13 @@ class AnnotationControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
@@ -69,7 +71,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_returns200_whenAuthenticated() throws Exception {
|
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()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
@@ -79,13 +81,13 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_withStatusParam_passesItToService() throws Exception {
|
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()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||||
.andExpect(status().isOk());
|
.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
|
@Test
|
||||||
@@ -112,12 +114,12 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_responseContainsTotalCount() throws Exception {
|
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()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.total").value(0))
|
.andExpect(jsonPath("$.totalElements").value(0))
|
||||||
.andExpect(jsonPath("$.items").isArray());
|
.andExpect(jsonPath("$.items").isArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +135,7 @@ class DocumentControllerTest {
|
|||||||
.build();
|
.build();
|
||||||
var matchData = new SearchMatchData(
|
var matchData = new SearchMatchData(
|
||||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
"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()))));
|
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||||
@@ -143,6 +145,70 @@ class DocumentControllerTest {
|
|||||||
.value("Er schrieb einen langen Brief"));
|
.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 ─────────────────────────────────────────────────
|
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -702,4 +768,476 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.editorName").value("Otto"));
|
.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;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||||
@@ -25,6 +28,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -183,19 +187,19 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Müller\"}"))
|
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +208,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +217,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,11 +229,53 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.firstName").value("Hans"));
|
.andExpect(jsonPath("$.firstName").value("Hans"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns200_forInstitution_withoutFirstName() throws Exception {
|
||||||
|
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
||||||
|
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.lastName").value("Verlag GmbH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_trimsTitle_beforePersisting() throws Exception {
|
||||||
|
ArgumentCaptor<org.raddatz.familienarchiv.dto.PersonUpdateDTO> captor =
|
||||||
|
ArgumentCaptor.forClass(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class);
|
||||||
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
|
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertThat(captor.getValue().getTitle()).isEqualTo("Prof.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns400_whenPersonTypeIsSkip() throws Exception {
|
||||||
|
when(personService.createPerson(any())).thenThrow(
|
||||||
|
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -242,10 +288,10 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +300,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +313,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.lastName").value("Müller"));
|
.andExpect(jsonPath("$.lastName").value("Müller"));
|
||||||
}
|
}
|
||||||
@@ -317,11 +363,10 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
// firstName valid, lastName blank → second || operand = true → 400
|
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +384,7 @@ class PersonControllerTest {
|
|||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||||
"\"notes\":\"Some notes\"}"))
|
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.firstName").value("Maria"))
|
.andExpect(jsonPath("$.firstName").value("Maria"))
|
||||||
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
||||||
@@ -355,7 +400,7 @@ class PersonControllerTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +411,7 @@ class PersonControllerTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +422,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,7 +431,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -260,6 +260,13 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(delete(URL_BLOCK))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
||||||
@@ -373,4 +380,63 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.reviewed").value(true));
|
.andExpect(jsonPath("$.reviewed").value(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PUT .../review-all ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
TranscriptionBlock b1 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
||||||
|
.text("Block 1").sortOrder(0).reviewed(true).build();
|
||||||
|
TranscriptionBlock b2 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
|
||||||
|
.text("Block 2").sortOrder(1).reviewed(true).build();
|
||||||
|
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||||
|
.thenReturn(List.of(b1, b2));
|
||||||
|
|
||||||
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray())
|
||||||
|
.andExpect(jsonPath("$[0].reviewed").value(true))
|
||||||
|
.andExpect(jsonPath("$[1].reviewed").value(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
|
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray())
|
||||||
|
.andExpect(jsonPath("$").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(null);
|
||||||
|
|
||||||
|
mockMvc.perform(put(URL_REVIEW_ALL))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -104,4 +106,55 @@ class UserControllerTest {
|
|||||||
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── permission enforcement ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "reader@example.com")
|
||||||
|
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/users")
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "reader@example.com")
|
||||||
|
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "reader@example.com")
|
||||||
|
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── unauthenticated access ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/users")
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -24,10 +25,43 @@ class DocumentSearchResultTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void of_total_equals_list_size() {
|
void of_totalElements_equals_list_size_for_unpaged_shortcut() {
|
||||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
|
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
|
@Test
|
||||||
@@ -53,9 +87,18 @@ class DocumentSearchResultTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
void totalElements_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||||
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
|
Schema schema = DocumentSearchResult.class.getDeclaredField("totalElements").getAnnotation(Schema.class);
|
||||||
assertThat(schema).isNotNull();
|
assertThat(schema).isNotNull();
|
||||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,22 @@ class DocumentFtsTest {
|
|||||||
assertThat(ids).isEmpty();
|
assertThat(ids).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word() {
|
||||||
|
// "Wille" stems to "will" via the German Snowball stemmer.
|
||||||
|
// "will" is also a German stop word, so to_tsquery('german','will:*') drops it.
|
||||||
|
// The prefix-transform step must use to_tsquery('simple',...) to avoid this.
|
||||||
|
Document doc = documentRepository.saveAndFlush(document("Foto"));
|
||||||
|
UUID annotationId = annotation(doc.getId());
|
||||||
|
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Der Wille des Volkes", 0));
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
|
||||||
|
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
|
||||||
|
|
||||||
|
assertThat(ids).contains(doc.getId());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
||||||
documentRepository.saveAndFlush(document("Brief"));
|
documentRepository.saveAndFlush(document("Brief"));
|
||||||
|
|||||||
@@ -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.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
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 org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -25,6 +26,8 @@ import static org.mockito.Mockito.when;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class DocumentServiceSortTest {
|
class DocumentServiceSortTest {
|
||||||
|
|
||||||
|
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
||||||
|
|
||||||
@Mock DocumentRepository documentRepository;
|
@Mock DocumentRepository documentRepository;
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@Mock FileService fileService;
|
@Mock FileService fileService;
|
||||||
@@ -51,12 +54,12 @@ class DocumentServiceSortTest {
|
|||||||
|
|
||||||
// FTS returns id1 first (higher rank), id2 second
|
// FTS returns id1 first (higher rank), id2 second
|
||||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||||
// findAll(spec, sort) — the correct date path — returns date-DESC order
|
// findAll(spec, pageable) — the correct date path — returns date-DESC order
|
||||||
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of(newer, older));
|
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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)
|
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
@@ -78,7 +81,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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)
|
// Expect: rank order restored (id1 first)
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
||||||
@@ -97,7 +100,7 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1));
|
.thenReturn(List.of(doc2, doc1));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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);
|
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.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.dto.MatchOffset;
|
import org.raddatz.familienarchiv.dto.MatchOffset;
|
||||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||||
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
@@ -24,6 +25,7 @@ import org.raddatz.familienarchiv.model.Tag;
|
|||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
@@ -46,6 +48,12 @@ import static org.mockito.Mockito.*;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class DocumentServiceTest {
|
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 DocumentRepository documentRepository;
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@Mock FileService fileService;
|
@Mock FileService fileService;
|
||||||
@@ -113,6 +121,23 @@ class DocumentServiceTest {
|
|||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_setsArchiveBoxAndFolder() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenReturn(doc);
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setArchiveBox("K-03");
|
||||||
|
dto.setArchiveFolder("Mappe B");
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null, null);
|
||||||
|
|
||||||
|
assertThat(doc.getArchiveBox()).isEqualTo("K-03");
|
||||||
|
assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1323,26 +1348,124 @@ class DocumentServiceTest {
|
|||||||
assertThat(result).isNull();
|
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 ─────────────────────────────────────
|
// ─── searchDocuments — status filter ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_passesStatusSpecificationToRepository() {
|
void searchDocuments_passesStatusSpecificationToRepository() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of());
|
.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
|
@Test
|
||||||
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of());
|
.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 ────────────────────────────────────────────────────
|
// ─── getRecentActivity ────────────────────────────────────────────────────
|
||||||
@@ -1418,7 +1541,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(withSender, noSender));
|
.thenReturn(List.of(withSender, noSender));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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()).hasSize(2);
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
||||||
@@ -1438,7 +1561,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(noReceivers, withReceiver));
|
.thenReturn(List.of(noReceivers, withReceiver));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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())
|
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||||
.containsExactly("Has Receiver", "No Receivers");
|
.containsExactly("Has Receiver", "No Receivers");
|
||||||
@@ -1460,7 +1583,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(docNullName, docSmith));
|
.thenReturn(List.of(docNullName, docSmith));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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")
|
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
||||||
@@ -1482,7 +1605,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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);
|
assertThat(result.items()).hasSize(1);
|
||||||
SearchMatchData md = result.items().get(0).matchData();
|
SearchMatchData md = result.items().get(0).matchData();
|
||||||
@@ -1492,11 +1615,12 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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();
|
assertThat(result.items()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -1515,7 +1639,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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();
|
SearchMatchData md = result.items().get(0).matchData();
|
||||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
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());
|
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");
|
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── personType + title in createPerson(PersonUpdateDTO) ─────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_persistsPersonType() {
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Walter"); dto.setLastName("de Gruyter"); dto.setPersonType(PersonType.INSTITUTION);
|
||||||
|
|
||||||
|
Person result = personService.createPerson(dto);
|
||||||
|
|
||||||
|
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_throwsInvalidPersonType_whenSkip() {
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.SKIP);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> personService.createPerson(dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_persistsTitle() {
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Dr."); dto.setLastName("Müller"); dto.setTitle("Prof."); dto.setPersonType(PersonType.PERSON);
|
||||||
|
|
||||||
|
Person result = personService.createPerson(dto);
|
||||||
|
|
||||||
|
assertThat(result.getTitle()).isEqualTo("Prof.");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -145,6 +182,36 @@ class PersonServiceTest {
|
|||||||
.isEqualTo(400);
|
.isEqualTo(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── updatePerson (personType) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_throwsInvalidPersonType_whenSkip() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.SKIP);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_persistsPersonType() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").personType(PersonType.PERSON).build();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.INSTITUTION);
|
||||||
|
|
||||||
|
Person result = personService.updatePerson(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -506,4 +506,86 @@ class TranscriptionServiceTest {
|
|||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── markAllBlocksReviewed ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||||
|
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||||
|
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||||
|
.thenReturn(List.of(block1, block2));
|
||||||
|
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||||
|
|
||||||
|
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
||||||
|
verify(blockRepository).saveAll(List.of(block1, block2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
||||||
|
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||||
|
.thenReturn(List.of(block));
|
||||||
|
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||||
|
|
||||||
|
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
|
||||||
|
verify(blockRepository).saveAll(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||||
|
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||||
|
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||||
|
.thenReturn(List.of(block1, block2));
|
||||||
|
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||||
|
|
||||||
|
verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
|
||||||
|
TranscriptionBlock unreviewed = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
|
||||||
|
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
|
||||||
|
.thenReturn(List.of(alreadyReviewed, unreviewed));
|
||||||
|
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||||
|
|
||||||
|
verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of());
|
||||||
|
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package org.raddatz.familienarchiv.service;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
@@ -34,6 +37,7 @@ class UserServiceTest {
|
|||||||
@Mock AppUserRepository userRepository;
|
@Mock AppUserRepository userRepository;
|
||||||
@Mock UserGroupRepository groupRepository;
|
@Mock UserGroupRepository groupRepository;
|
||||||
@Mock PasswordEncoder passwordEncoder;
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
|
@Mock AuditService auditService;
|
||||||
@InjectMocks UserService userService;
|
@InjectMocks UserService userService;
|
||||||
|
|
||||||
// ─── findByEmail ──────────────────────────────────────────────────────────
|
// ─── findByEmail ──────────────────────────────────────────────────────────
|
||||||
@@ -61,7 +65,7 @@ class UserServiceTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.deleteUser(id))
|
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id))
|
||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +75,7 @@ class UserServiceTest {
|
|||||||
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
||||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
userService.deleteUser(id);
|
userService.deleteUser(UUID.randomUUID(), id);
|
||||||
|
|
||||||
verify(userRepository).delete(user);
|
verify(userRepository).delete(user);
|
||||||
}
|
}
|
||||||
@@ -90,7 +94,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
AppUser result = userService.createUserOrUpdate(req);
|
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(userRepository).save(any());
|
verify(userRepository).save(any());
|
||||||
@@ -108,7 +112,7 @@ class UserServiceTest {
|
|||||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||||
when(userRepository.save(any())).thenReturn(existing);
|
when(userRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
userService.createUserOrUpdate(req);
|
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
verify(userRepository, times(1)).save(existing);
|
verify(userRepository, times(1)).save(existing);
|
||||||
}
|
}
|
||||||
@@ -229,7 +233,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getFirstName()).isEqualTo("Ada");
|
assertThat(result.getFirstName()).isEqualTo("Ada");
|
||||||
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
||||||
@@ -246,7 +250,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setFirstName("Ada");
|
dto.setFirstName("Ada");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).containsExactly(adminGroup);
|
assertThat(result.getGroups()).containsExactly(adminGroup);
|
||||||
}
|
}
|
||||||
@@ -264,7 +268,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setGroupIds(List.of(newGroup.getId()));
|
dto.setGroupIds(List.of(newGroup.getId()));
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).containsExactly(newGroup);
|
assertThat(result.getGroups()).containsExactly(newGroup);
|
||||||
}
|
}
|
||||||
@@ -281,7 +285,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setGroupIds(List.of());
|
dto.setGroupIds(List.of());
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).isEmpty();
|
assertThat(result.getGroups()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -313,7 +317,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
AppUser result = userService.createUserOrUpdate(req);
|
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(groupRepository).findAllById(List.of(group.getId()));
|
verify(groupRepository).findAllById(List.of(group.getId()));
|
||||||
@@ -378,7 +382,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setNewPassword("newSecret");
|
dto.setNewPassword("newSecret");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getPassword()).isEqualTo("newHashed");
|
assertThat(result.getPassword()).isEqualTo("newHashed");
|
||||||
}
|
}
|
||||||
@@ -393,7 +397,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setNewPassword(" ");
|
dto.setNewPassword(" ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getPassword()).isEqualTo("original");
|
assertThat(result.getPassword()).isEqualTo("original");
|
||||||
verify(passwordEncoder, never()).encode(any());
|
verify(passwordEncoder, never()).encode(any());
|
||||||
@@ -408,7 +412,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail(" ");
|
dto.setEmail(" ");
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining("blank");
|
.hasMessageContaining("blank");
|
||||||
}
|
}
|
||||||
@@ -425,7 +429,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail("taken@example.com");
|
dto.setEmail("taken@example.com");
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining("E-Mail");
|
.hasMessageContaining("E-Mail");
|
||||||
}
|
}
|
||||||
@@ -497,7 +501,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
userService.createUserOrUpdate(req);
|
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
verify(groupRepository, never()).findAllById(any());
|
verify(groupRepository, never()).findAllById(any());
|
||||||
}
|
}
|
||||||
@@ -561,7 +565,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(null);
|
dto.setContact(null);
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isNull();
|
assertThat(result.getContact()).isNull();
|
||||||
}
|
}
|
||||||
@@ -576,7 +580,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(" ");
|
dto.setContact(" ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isNull();
|
assertThat(result.getContact()).isNull();
|
||||||
}
|
}
|
||||||
@@ -591,7 +595,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(" phone: 555 ");
|
dto.setContact(" phone: 555 ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isEqualTo("phone: 555");
|
assertThat(result.getContact()).isEqualTo("phone: 555");
|
||||||
}
|
}
|
||||||
@@ -606,7 +610,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail(null);
|
dto.setEmail(null);
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
|
|
||||||
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
||||||
}
|
}
|
||||||
@@ -622,7 +626,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail("me@example.com");
|
dto.setEmail("me@example.com");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(id, dto);
|
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
||||||
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +644,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
userService.createUserOrUpdate(req);
|
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
||||||
|
|
||||||
verify(groupRepository, never()).findAllById(any());
|
verify(groupRepository, never()).findAllById(any());
|
||||||
}
|
}
|
||||||
@@ -699,6 +703,160 @@ class UserServiceTest {
|
|||||||
assertThat(result).containsExactly(g);
|
assertThat(result).containsExactly(g);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── audit: GROUP_MEMBERSHIP_CHANGED ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").permissions(Set.of("READ_ALL")).build();
|
||||||
|
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").permissions(Set.of("WRITE_ALL")).build();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(oldGroup)).build();
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||||
|
when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setGroupIds(List.of(newGroup.getId()));
|
||||||
|
|
||||||
|
userService.adminUpdateUser(actorId, userId, dto);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
org.mockito.ArgumentMatchers.eq(AuditKind.GROUP_MEMBERSHIP_CHANGED),
|
||||||
|
org.mockito.ArgumentMatchers.eq(actorId),
|
||||||
|
org.mockito.ArgumentMatchers.isNull(),
|
||||||
|
payloadCaptor.capture());
|
||||||
|
java.util.Map<String, Object> payload = payloadCaptor.getValue();
|
||||||
|
assertThat(payload).containsEntry("email", "u@example.com");
|
||||||
|
assertThat((java.util.List<String>) payload.get("addedGroups")).containsExactly("Editors");
|
||||||
|
assertThat((java.util.List<String>) payload.get("removedGroups")).containsExactly("Viewers");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchanged() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||||
|
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setGroupIds(List.of(group.getId()));
|
||||||
|
|
||||||
|
userService.adminUpdateUser(actorId, userId, dto);
|
||||||
|
|
||||||
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNull() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
// groupIds not set → null
|
||||||
|
|
||||||
|
userService.adminUpdateUser(actorId, userId, dto);
|
||||||
|
|
||||||
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── audit: USER_DELETED ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUser_logsUserDeleted_withEmailInPayload() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("gone@example.com").build();
|
||||||
|
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
userService.deleteUser(actorId, userId);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
org.mockito.ArgumentMatchers.eq(AuditKind.USER_DELETED),
|
||||||
|
org.mockito.ArgumentMatchers.eq(actorId),
|
||||||
|
org.mockito.ArgumentMatchers.isNull(),
|
||||||
|
payloadCaptor.capture());
|
||||||
|
assertThat(payloadCaptor.getValue()).containsEntry("email", "gone@example.com");
|
||||||
|
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── audit: USER_CREATED ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserOrUpdate_logsUserCreated_whenUserIsNew() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setEmail("new@example.com");
|
||||||
|
req.setInitialPassword("secret");
|
||||||
|
req.setGroupIds(List.of());
|
||||||
|
|
||||||
|
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
|
||||||
|
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
||||||
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||||
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
userService.createUserOrUpdate(actorId, req);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
org.mockito.ArgumentMatchers.eq(AuditKind.USER_CREATED),
|
||||||
|
org.mockito.ArgumentMatchers.eq(actorId),
|
||||||
|
org.mockito.ArgumentMatchers.isNull(),
|
||||||
|
payloadCaptor.capture());
|
||||||
|
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
||||||
|
assertThat(payloadCaptor.getValue()).containsEntry("email", "new@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists() {
|
||||||
|
UUID actorId = UUID.randomUUID();
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setEmail("existing@example.com");
|
||||||
|
req.setInitialPassword("pass");
|
||||||
|
req.setGroupIds(List.of());
|
||||||
|
|
||||||
|
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build();
|
||||||
|
when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing));
|
||||||
|
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||||
|
when(userRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
|
userService.createUserOrUpdate(actorId, req);
|
||||||
|
|
||||||
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createUserForBootstrap ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserForBootstrap_createsUserWithoutAuditEvent() {
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setEmail("bootstrap@example.com");
|
||||||
|
req.setInitialPassword("secret");
|
||||||
|
req.setGroupIds(List.of());
|
||||||
|
|
||||||
|
when(userRepository.findByEmail("bootstrap@example.com")).thenReturn(Optional.empty());
|
||||||
|
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
||||||
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("bootstrap@example.com").build();
|
||||||
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
AppUser result = userService.createUserForBootstrap(req);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(saved);
|
||||||
|
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── createGroup ──────────────────────────────────────────────────────────
|
// ─── createGroup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,6 +40,26 @@ export default defineConfig(
|
|||||||
parser: ts.parser,
|
parser: ts.parser,
|
||||||
svelteConfig
|
svelteConfig
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// text-accent resolves to #a1dcd8 in light mode (1.52:1 on white — WCAG fail).
|
||||||
|
// layout.css documents it as decorative-only (borders, icon tints, bg fills).
|
||||||
|
// For any text label use text-primary or text-ink instead. This rule catches
|
||||||
|
// the pattern where text-accent appears inside a JavaScript string literal
|
||||||
|
// (e.g. conditional ternary class expressions in Svelte templates).
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: 'Literal[value=/\\btext-accent\\b/]',
|
||||||
|
message:
|
||||||
|
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'TemplateLiteral > TemplateElement[value.raw=/\\btext-accent\\b/]',
|
||||||
|
message:
|
||||||
|
'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
"nav_conversations": "Briefwechsel",
|
"nav_conversations": "Briefwechsel",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Abmelden",
|
"nav_logout": "Abmelden",
|
||||||
|
"theme_toggle_to_light": "Zu hellem Design wechseln",
|
||||||
|
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
|
||||||
"btn_save": "Speichern",
|
"btn_save": "Speichern",
|
||||||
"btn_cancel": "Abbrechen",
|
"btn_cancel": "Abbrechen",
|
||||||
"btn_confirm": "Bestätigen",
|
"btn_confirm": "Bestätigen",
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||||
"btn_back": "Zurück",
|
"btn_back": "Zurück",
|
||||||
"btn_back_to_document": "Zurück zum Dokument",
|
"btn_back_to_document": "Zurück zum Dokument",
|
||||||
|
"form_label_person_type": "Typ",
|
||||||
|
"form_label_name": "Name",
|
||||||
"form_label_first_name": "Vorname",
|
"form_label_first_name": "Vorname",
|
||||||
"form_label_last_name": "Nachname",
|
"form_label_last_name": "Nachname",
|
||||||
"form_label_alias": "Rufname / Alias",
|
"form_label_alias": "Rufname / Alias",
|
||||||
@@ -499,7 +503,7 @@
|
|||||||
"transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?",
|
"transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?",
|
||||||
"transcription_block_history_btn": "Verlauf",
|
"transcription_block_history_btn": "Verlauf",
|
||||||
"transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen",
|
"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_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren",
|
||||||
"transcription_quote_stale": "Zitat aus älterer Version",
|
"transcription_quote_stale": "Zitat aus älterer Version",
|
||||||
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
|
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
|
||||||
@@ -515,7 +519,6 @@
|
|||||||
"scan_collapse": "Scan verkleinern",
|
"scan_collapse": "Scan verkleinern",
|
||||||
"transcription_empty_title": "Noch keine Transkription",
|
"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_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",
|
"transcription_panel_close": "Panel schließen",
|
||||||
"person_alias_heading": "Namensverlauf",
|
"person_alias_heading": "Namensverlauf",
|
||||||
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
|
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
|
||||||
@@ -528,6 +531,7 @@
|
|||||||
"person_type_INSTITUTION": "Institution",
|
"person_type_INSTITUTION": "Institution",
|
||||||
"person_type_GROUP": "Gruppe",
|
"person_type_GROUP": "Gruppe",
|
||||||
"person_type_UNKNOWN": "Unbekannt",
|
"person_type_UNKNOWN": "Unbekannt",
|
||||||
|
"a11y_type_changed": "Typ geändert zu {type}",
|
||||||
"person_alias_add_heading": "Name hinzufuegen",
|
"person_alias_add_heading": "Name hinzufuegen",
|
||||||
"person_alias_label_type": "Art",
|
"person_alias_label_type": "Art",
|
||||||
"person_alias_label_last_name": "Nachname",
|
"person_alias_label_last_name": "Nachname",
|
||||||
@@ -537,6 +541,9 @@
|
|||||||
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
||||||
"person_alias_btn_delete": "Entfernen",
|
"person_alias_btn_delete": "Entfernen",
|
||||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||||
|
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||||
|
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||||
|
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||||
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
|
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
|
||||||
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
|
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
|
||||||
@@ -806,5 +813,99 @@
|
|||||||
"chronik_load_more": "Mehr laden",
|
"chronik_load_more": "Mehr laden",
|
||||||
"chronik_loading": "Lädt …",
|
"chronik_loading": "Lädt …",
|
||||||
"chronik_load_more_announcement": "{count} weitere Einträge geladen",
|
"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",
|
||||||
|
"pagination_page_button": "Seite {page}",
|
||||||
|
|
||||||
|
"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_conversations": "Letters",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Sign out",
|
"nav_logout": "Sign out",
|
||||||
|
"theme_toggle_to_light": "Switch to light mode",
|
||||||
|
"theme_toggle_to_dark": "Switch to dark mode",
|
||||||
"btn_save": "Save",
|
"btn_save": "Save",
|
||||||
"btn_cancel": "Cancel",
|
"btn_cancel": "Cancel",
|
||||||
"btn_confirm": "Confirm",
|
"btn_confirm": "Confirm",
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
"btn_back_to_overview": "Back to overview",
|
"btn_back_to_overview": "Back to overview",
|
||||||
"btn_back": "Back",
|
"btn_back": "Back",
|
||||||
"btn_back_to_document": "Back to document",
|
"btn_back_to_document": "Back to document",
|
||||||
|
"form_label_person_type": "Type",
|
||||||
|
"form_label_name": "Name",
|
||||||
"form_label_first_name": "First name",
|
"form_label_first_name": "First name",
|
||||||
"form_label_last_name": "Last name",
|
"form_label_last_name": "Last name",
|
||||||
"form_label_alias": "Nickname / Alias",
|
"form_label_alias": "Nickname / Alias",
|
||||||
@@ -499,7 +503,7 @@
|
|||||||
"transcription_block_delete_confirm": "Really delete this block and all its comments?",
|
"transcription_block_delete_confirm": "Really delete this block and all its comments?",
|
||||||
"transcription_block_history_btn": "History",
|
"transcription_block_history_btn": "History",
|
||||||
"transcription_empty_cta": "Mark a region on the scan to start transcribing",
|
"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_draw_tooltip": "Click and drag to mark a text region",
|
||||||
"transcription_quote_stale": "Quote from an older version",
|
"transcription_quote_stale": "Quote from an older version",
|
||||||
"transcription_block_conflict": "This block was changed by someone else — please reload",
|
"transcription_block_conflict": "This block was changed by someone else — please reload",
|
||||||
@@ -515,7 +519,6 @@
|
|||||||
"scan_collapse": "Collapse scan",
|
"scan_collapse": "Collapse scan",
|
||||||
"transcription_empty_title": "No transcription yet",
|
"transcription_empty_title": "No transcription yet",
|
||||||
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
|
"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",
|
"transcription_panel_close": "Close panel",
|
||||||
"person_alias_heading": "Name history",
|
"person_alias_heading": "Name history",
|
||||||
"person_alias_empty": "No name changes recorded yet.",
|
"person_alias_empty": "No name changes recorded yet.",
|
||||||
@@ -528,6 +531,7 @@
|
|||||||
"person_type_INSTITUTION": "Institution",
|
"person_type_INSTITUTION": "Institution",
|
||||||
"person_type_GROUP": "Group",
|
"person_type_GROUP": "Group",
|
||||||
"person_type_UNKNOWN": "Unknown",
|
"person_type_UNKNOWN": "Unknown",
|
||||||
|
"a11y_type_changed": "Type changed to {type}",
|
||||||
"person_alias_add_heading": "Add name",
|
"person_alias_add_heading": "Add name",
|
||||||
"person_alias_label_type": "Type",
|
"person_alias_label_type": "Type",
|
||||||
"person_alias_label_last_name": "Last name",
|
"person_alias_label_last_name": "Last name",
|
||||||
@@ -537,6 +541,9 @@
|
|||||||
"person_alias_delete_body": "This name will be removed from search results.",
|
"person_alias_delete_body": "This name will be removed from search results.",
|
||||||
"person_alias_btn_delete": "Remove",
|
"person_alias_btn_delete": "Remove",
|
||||||
"error_alias_not_found": "The name alias was not found.",
|
"error_alias_not_found": "The name alias was not found.",
|
||||||
|
"error_invalid_person_type": "The specified person type is not valid.",
|
||||||
|
"validation_last_name_required": "Last name is required.",
|
||||||
|
"validation_first_name_required": "First name is required.",
|
||||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||||
"error_ocr_job_not_found": "The OCR job was not found.",
|
"error_ocr_job_not_found": "The OCR job was not found.",
|
||||||
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",
|
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",
|
||||||
@@ -806,5 +813,99 @@
|
|||||||
"chronik_load_more": "Load more",
|
"chronik_load_more": "Load more",
|
||||||
"chronik_loading": "Loading …",
|
"chronik_loading": "Loading …",
|
||||||
"chronik_load_more_announcement": "{count} more entries loaded",
|
"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",
|
||||||
|
"pagination_page_button": "Page {page}",
|
||||||
|
|
||||||
|
"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_conversations": "Cartas",
|
||||||
"nav_admin": "Admin",
|
"nav_admin": "Admin",
|
||||||
"nav_logout": "Cerrar sesión",
|
"nav_logout": "Cerrar sesión",
|
||||||
|
"theme_toggle_to_light": "Cambiar a modo claro",
|
||||||
|
"theme_toggle_to_dark": "Cambiar a modo oscuro",
|
||||||
"btn_save": "Guardar",
|
"btn_save": "Guardar",
|
||||||
"btn_cancel": "Cancelar",
|
"btn_cancel": "Cancelar",
|
||||||
"btn_confirm": "Confirmar",
|
"btn_confirm": "Confirmar",
|
||||||
@@ -33,6 +35,8 @@
|
|||||||
"btn_back_to_overview": "Volver al resumen",
|
"btn_back_to_overview": "Volver al resumen",
|
||||||
"btn_back": "Volver",
|
"btn_back": "Volver",
|
||||||
"btn_back_to_document": "Volver al documento",
|
"btn_back_to_document": "Volver al documento",
|
||||||
|
"form_label_person_type": "Tipo",
|
||||||
|
"form_label_name": "Nombre",
|
||||||
"form_label_first_name": "Nombre",
|
"form_label_first_name": "Nombre",
|
||||||
"form_label_last_name": "Apellido",
|
"form_label_last_name": "Apellido",
|
||||||
"form_label_alias": "Apodo / Alias",
|
"form_label_alias": "Apodo / Alias",
|
||||||
@@ -499,7 +503,7 @@
|
|||||||
"transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?",
|
"transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?",
|
||||||
"transcription_block_history_btn": "Historial",
|
"transcription_block_history_btn": "Historial",
|
||||||
"transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir",
|
"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_draw_tooltip": "Haga clic y arrastre para marcar una región de texto",
|
||||||
"transcription_quote_stale": "Cita de una versión anterior",
|
"transcription_quote_stale": "Cita de una versión anterior",
|
||||||
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
|
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
|
||||||
@@ -515,7 +519,6 @@
|
|||||||
"scan_collapse": "Reducir escaneo",
|
"scan_collapse": "Reducir escaneo",
|
||||||
"transcription_empty_title": "Sin transcripcion",
|
"transcription_empty_title": "Sin transcripcion",
|
||||||
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una 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",
|
"transcription_panel_close": "Cerrar panel",
|
||||||
"person_alias_heading": "Historial de nombres",
|
"person_alias_heading": "Historial de nombres",
|
||||||
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
|
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
|
||||||
@@ -528,6 +531,7 @@
|
|||||||
"person_type_INSTITUTION": "Institución",
|
"person_type_INSTITUTION": "Institución",
|
||||||
"person_type_GROUP": "Grupo",
|
"person_type_GROUP": "Grupo",
|
||||||
"person_type_UNKNOWN": "Desconocido",
|
"person_type_UNKNOWN": "Desconocido",
|
||||||
|
"a11y_type_changed": "Tipo cambiado a {type}",
|
||||||
"person_alias_add_heading": "Agregar nombre",
|
"person_alias_add_heading": "Agregar nombre",
|
||||||
"person_alias_label_type": "Tipo",
|
"person_alias_label_type": "Tipo",
|
||||||
"person_alias_label_last_name": "Apellido",
|
"person_alias_label_last_name": "Apellido",
|
||||||
@@ -537,6 +541,9 @@
|
|||||||
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
||||||
"person_alias_btn_delete": "Eliminar",
|
"person_alias_btn_delete": "Eliminar",
|
||||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||||
|
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||||
|
"validation_last_name_required": "El apellido es obligatorio.",
|
||||||
|
"validation_first_name_required": "El nombre es obligatorio.",
|
||||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||||
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
|
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
|
||||||
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
|
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
|
||||||
@@ -806,5 +813,99 @@
|
|||||||
"chronik_load_more": "Cargar más",
|
"chronik_load_more": "Cargar más",
|
||||||
"chronik_loading": "Cargando …",
|
"chronik_loading": "Cargando …",
|
||||||
"chronik_load_more_announcement": "{count} entradas más cargadas",
|
"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",
|
||||||
|
"pagination_page_button": "Página {page}",
|
||||||
|
|
||||||
|
"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: {
|
use: {
|
||||||
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
|
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
|
||||||
locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German
|
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
|
screenshot: 'on', // always capture screenshots
|
||||||
video: 'retain-on-failure',
|
video: 'retain-on-failure',
|
||||||
trace: '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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ let {
|
|||||||
dimmed = false,
|
dimmed = false,
|
||||||
flashAnnotationId = null,
|
flashAnnotationId = null,
|
||||||
onDraw,
|
onDraw,
|
||||||
onAnnotationClick
|
onAnnotationClick,
|
||||||
|
onDeleteRequest
|
||||||
}: {
|
}: {
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
canDraw: boolean;
|
canDraw: boolean;
|
||||||
@@ -29,6 +30,7 @@ let {
|
|||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
onDraw: (rect: DrawRect) => void;
|
onDraw: (rect: DrawRect) => void;
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
|
onDeleteRequest?: (annotationId: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let drawStart = $state<{ x: number; y: number } | null>(null);
|
let drawStart = $state<{ x: number; y: number } | null>(null);
|
||||||
@@ -112,6 +114,8 @@ const containerStyle = $derived(
|
|||||||
dimmed={dimmed}
|
dimmed={dimmed}
|
||||||
blockNumber={blockNumbers[annotation.id]}
|
blockNumber={blockNumbers[annotation.id]}
|
||||||
isFlashing={flashAnnotationId === annotation.id}
|
isFlashing={flashAnnotationId === annotation.id}
|
||||||
|
showDelete={canDraw}
|
||||||
|
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
|
||||||
onclick={() => onAnnotationClick?.(annotation.id)}
|
onclick={() => onAnnotationClick?.(annotation.id)}
|
||||||
onpointerenter={() => (hoveredId = annotation.id)}
|
onpointerenter={() => (hoveredId = annotation.id)}
|
||||||
onpointerleave={() => (hoveredId = null)}
|
onpointerleave={() => (hoveredId = null)}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe('AnnotationLayer', () => {
|
|||||||
expect(el2.style.opacity).toBe('1');
|
expect(el2.style.opacity).toBe('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show delete buttons (annotations owned by blocks)', async () => {
|
it('does not show delete button when annotation is not hovered or active', async () => {
|
||||||
render(AnnotationLayer, {
|
render(AnnotationLayer, {
|
||||||
annotations: [makeAnnotation('ann-1')],
|
annotations: [makeAnnotation('ann-1')],
|
||||||
canDraw: true,
|
canDraw: true,
|
||||||
@@ -107,6 +107,19 @@ describe('AnnotationLayer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button when canDraw is false even if annotation is active', async () => {
|
||||||
|
render(AnnotationLayer, {
|
||||||
|
annotations: [makeAnnotation('ann-1')],
|
||||||
|
canDraw: false,
|
||||||
|
color: '#00C7B1',
|
||||||
|
activeAnnotationId: 'ann-1',
|
||||||
|
onDraw: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ let {
|
|||||||
blockNumber = undefined,
|
blockNumber = undefined,
|
||||||
isFlashing = false,
|
isFlashing = false,
|
||||||
isResizable = false,
|
isResizable = false,
|
||||||
|
showDelete = false,
|
||||||
|
onDeleteRequest,
|
||||||
onclick,
|
onclick,
|
||||||
onpointerenter,
|
onpointerenter,
|
||||||
onpointerleave
|
onpointerleave
|
||||||
@@ -23,11 +25,15 @@ let {
|
|||||||
blockNumber?: number | undefined;
|
blockNumber?: number | undefined;
|
||||||
isFlashing?: boolean;
|
isFlashing?: boolean;
|
||||||
isResizable?: boolean;
|
isResizable?: boolean;
|
||||||
|
showDelete?: boolean;
|
||||||
|
onDeleteRequest?: () => void;
|
||||||
onclick: () => void;
|
onclick: () => void;
|
||||||
onpointerenter: () => void;
|
onpointerenter: () => void;
|
||||||
onpointerleave: () => void;
|
onpointerleave: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const deleteVisible = $derived(showDelete && (isHovered || isActive));
|
||||||
|
|
||||||
function hexToRgba(hex: string, alpha: number): string {
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
const r = parseInt(hex.slice(1, 3), 16);
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
const g = parseInt(hex.slice(3, 5), 16);
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
@@ -83,6 +89,7 @@ let shapeStyle = $derived(
|
|||||||
onclick={onclick}
|
onclick={onclick}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') onclick();
|
if (e.key === 'Enter' || e.key === ' ') onclick();
|
||||||
|
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
|
||||||
}}
|
}}
|
||||||
onpointerenter={onpointerenter}
|
onpointerenter={onpointerenter}
|
||||||
onpointerleave={onpointerleave}
|
onpointerleave={onpointerleave}
|
||||||
@@ -112,6 +119,51 @@ let shapeStyle = $derived(
|
|||||||
{blockNumber}
|
{blockNumber}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if deleteVisible}
|
||||||
|
<button
|
||||||
|
data-testid="annotation-delete-{annotation.id}"
|
||||||
|
type="button"
|
||||||
|
aria-label="Löschen"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteRequest?.();
|
||||||
|
}}
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid var(--color-error, #e53e3e);
|
||||||
|
color: var(--color-error, #e53e3e);
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
||||||
|
z-index: 10;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if isResizable}
|
{#if isResizable}
|
||||||
<AnnotationEditOverlay annotation={annotation} />
|
<AnnotationEditOverlay annotation={annotation} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
177
frontend/src/lib/components/AnnotationShape.svelte.spec.ts
Normal file
177
frontend/src/lib/components/AnnotationShape.svelte.spec.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import AnnotationShape from './AnnotationShape.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function makeAnnotation(id = 'ann-1') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
documentId: 'doc-1',
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.2,
|
||||||
|
color: '#00C7B1',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AnnotationShape', () => {
|
||||||
|
it('renders the annotation element', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: false,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button when showDelete is false', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: false,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button when showDelete is true but neither hovered nor active', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows delete button when showDelete is true and isHovered is true', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows delete button when showDelete is true and isActive is true', async () => {
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: true,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest: vi.fn(),
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDeleteRequest when delete button is clicked', async () => {
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
|
||||||
|
await deleteBtn.click();
|
||||||
|
|
||||||
|
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onclick when delete button is clicked', async () => {
|
||||||
|
const onclick = vi.fn();
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: true,
|
||||||
|
isActive: false,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick,
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
|
||||||
|
await deleteBtn.click();
|
||||||
|
|
||||||
|
expect(onclick).not.toHaveBeenCalled();
|
||||||
|
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => {
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: true,
|
||||||
|
showDelete: true,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
|
||||||
|
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
||||||
|
|
||||||
|
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onDeleteRequest on Delete key when showDelete is false', async () => {
|
||||||
|
const onDeleteRequest = vi.fn();
|
||||||
|
|
||||||
|
render(AnnotationShape, {
|
||||||
|
annotation: makeAnnotation(),
|
||||||
|
isHovered: false,
|
||||||
|
isActive: true,
|
||||||
|
showDelete: false,
|
||||||
|
onDeleteRequest,
|
||||||
|
onclick: () => {},
|
||||||
|
onpointerenter: () => {},
|
||||||
|
onpointerleave: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
|
||||||
|
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
||||||
|
|
||||||
|
expect(onDeleteRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { thumbnailUrl } from '$lib/thumbnails';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type Doc = {
|
type Doc = Pick<
|
||||||
id: string;
|
components['schemas']['Document'],
|
||||||
thumbnailKey?: string;
|
'id' | 'thumbnailUrl' | 'thumbnailAspect' | 'pageCount'
|
||||||
thumbnailGeneratedAt?: string;
|
>;
|
||||||
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
|
|
||||||
pageCount?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { doc }: { doc: Doc } = $props();
|
let { doc }: { doc: Doc } = $props();
|
||||||
|
|
||||||
const url = $derived(thumbnailUrl(doc));
|
const url = $derived(doc.thumbnailUrl ?? null);
|
||||||
const aspect = $derived(doc.thumbnailAspect ?? 'PORTRAIT');
|
const aspect = $derived(doc.thumbnailAspect ?? 'PORTRAIT');
|
||||||
const pageCount = $derived(doc.pageCount ?? 1);
|
const pageCount = $derived(doc.pageCount ?? 1);
|
||||||
const tileClass = $derived(aspect === 'LANDSCAPE' ? 'h-[120px] w-[168px]' : 'h-[168px] w-[120px]');
|
const tileClass = $derived(aspect === 'LANDSCAPE' ? 'h-[120px] w-[168px]' : 'h-[168px] w-[120px]');
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ describe('ConversationThumbnail', () => {
|
|||||||
render(ConversationThumbnail, {
|
render(ConversationThumbnail, {
|
||||||
doc: {
|
doc: {
|
||||||
id: '1111',
|
id: '1111',
|
||||||
thumbnailKey: 'thumbnails/1111.jpg',
|
thumbnailUrl: '/api/documents/1111/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||||
thumbnailGeneratedAt: '2026-04-10T09:00:00Z',
|
|
||||||
thumbnailAspect: 'PORTRAIT',
|
thumbnailAspect: 'PORTRAIT',
|
||||||
pageCount: 1
|
pageCount: 1
|
||||||
}
|
}
|
||||||
@@ -29,7 +28,7 @@ describe('ConversationThumbnail', () => {
|
|||||||
render(ConversationThumbnail, {
|
render(ConversationThumbnail, {
|
||||||
doc: {
|
doc: {
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
thumbnailKey: 'thumbnails/p1.jpg',
|
thumbnailUrl: '/api/documents/p1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||||
thumbnailAspect: 'PORTRAIT',
|
thumbnailAspect: 'PORTRAIT',
|
||||||
pageCount: 1
|
pageCount: 1
|
||||||
}
|
}
|
||||||
@@ -43,7 +42,7 @@ describe('ConversationThumbnail', () => {
|
|||||||
render(ConversationThumbnail, {
|
render(ConversationThumbnail, {
|
||||||
doc: {
|
doc: {
|
||||||
id: 'l1',
|
id: 'l1',
|
||||||
thumbnailKey: 'thumbnails/l1.jpg',
|
thumbnailUrl: '/api/documents/l1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||||
thumbnailAspect: 'LANDSCAPE',
|
thumbnailAspect: 'LANDSCAPE',
|
||||||
pageCount: 1
|
pageCount: 1
|
||||||
}
|
}
|
||||||
@@ -57,7 +56,7 @@ describe('ConversationThumbnail', () => {
|
|||||||
render(ConversationThumbnail, {
|
render(ConversationThumbnail, {
|
||||||
doc: {
|
doc: {
|
||||||
id: 'n1',
|
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, {
|
render(ConversationThumbnail, {
|
||||||
doc: {
|
doc: {
|
||||||
id: 'm1',
|
id: 'm1',
|
||||||
thumbnailKey: 'thumbnails/m1.jpg',
|
thumbnailUrl: '/api/documents/m1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||||
thumbnailAspect: 'PORTRAIT',
|
thumbnailAspect: 'PORTRAIT',
|
||||||
pageCount: 4
|
pageCount: 4
|
||||||
}
|
}
|
||||||
@@ -87,7 +86,7 @@ describe('ConversationThumbnail', () => {
|
|||||||
render(ConversationThumbnail, {
|
render(ConversationThumbnail, {
|
||||||
doc: {
|
doc: {
|
||||||
id: 's1',
|
id: 's1',
|
||||||
thumbnailKey: 'thumbnails/s1.jpg',
|
thumbnailUrl: '/api/documents/s1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
|
||||||
thumbnailAspect: 'PORTRAIT',
|
thumbnailAspect: 'PORTRAIT',
|
||||||
pageCount: 1
|
pageCount: 1
|
||||||
}
|
}
|
||||||
@@ -97,7 +96,7 @@ describe('ConversationThumbnail', () => {
|
|||||||
expect(badge).toBeNull();
|
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, {
|
render(ConversationThumbnail, {
|
||||||
doc: {
|
doc: {
|
||||||
id: 'blank',
|
id: 'blank',
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api';
|
|||||||
import { applyOffsets } from '$lib/search';
|
import { applyOffsets } from '$lib/search';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||||
import ProgressRing from './ProgressRing.svelte';
|
import ProgressRing from './ProgressRing.svelte';
|
||||||
import ContributorStack from './ContributorStack.svelte';
|
import ContributorStack from './ContributorStack.svelte';
|
||||||
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
||||||
|
|
||||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
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 doc = $derived(item.document);
|
||||||
const titleText = $derived(doc.title || doc.originalFilename);
|
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>
|
<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="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
|
||||||
<div class="flex gap-3 sm:gap-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 -->
|
<!-- Thumbnail tile -->
|
||||||
<DocumentThumbnail doc={doc} size="lg" />
|
<DocumentThumbnail doc={doc} size="lg" />
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import DocumentRow from './DocumentRow.svelte';
|
import DocumentRow from './DocumentRow.svelte';
|
||||||
|
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
@@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.mocked(goto).mockClear();
|
vi.mocked(goto).mockClear();
|
||||||
|
bulkSelectionStore.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
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 ─────────────────────────────────────────
|
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
|
||||||
|
|
||||||
describe('DocumentRow – progress ring and contributors', () => {
|
describe('DocumentRow – progress ring and contributors', () => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Props = {
|
|||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
onAnnotationClick: (id: string) => void;
|
onAnnotationClick: (id: string) => void;
|
||||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||||
|
onDeleteAnnotationRequest?: (annotationId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -38,7 +39,8 @@ let {
|
|||||||
annotationsDimmed = false,
|
annotationsDimmed = false,
|
||||||
flashAnnotationId = null,
|
flashAnnotationId = null,
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onTranscriptionDraw
|
onTranscriptionDraw,
|
||||||
|
onDeleteAnnotationRequest
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ let {
|
|||||||
flashAnnotationId={flashAnnotationId}
|
flashAnnotationId={flashAnnotationId}
|
||||||
onAnnotationClick={onAnnotationClick}
|
onAnnotationClick={onAnnotationClick}
|
||||||
onTranscriptionDraw={onTranscriptionDraw}
|
onTranscriptionDraw={onTranscriptionDraw}
|
||||||
|
onDeleteAnnotationRequest={onDeleteAnnotationRequest}
|
||||||
documentFileHash={doc.fileHash ?? null}
|
documentFileHash={doc.fileHash ?? null}
|
||||||
/>
|
/>
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
|
|||||||
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) {
|
function attachBellButton(node: HTMLButtonElement) {
|
||||||
bellButtonEl = node;
|
bellButtonEl = node;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -72,12 +78,11 @@ onDestroy(() => {
|
|||||||
{@attach attachBellButton}
|
{@attach attachBellButton}
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleDropdown}
|
onclick={toggleDropdown}
|
||||||
aria-label={stream.unreadCount > 0
|
aria-label={bellLabel}
|
||||||
? m.notification_bell_unread_label({ count: stream.unreadCount })
|
title={bellLabel}
|
||||||
: m.notification_bell_label()}
|
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -55,6 +55,34 @@ async function openDropdownAndClickFirstNotification() {
|
|||||||
notifButton.click();
|
notifButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('NotificationBell — cursor and tooltip', () => {
|
||||||
|
it('bell button has cursor-pointer class', async () => {
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.classList.contains('cursor-pointer')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bell button title equals aria-label when unreadCount is 0', async () => {
|
||||||
|
mockNotificationList.value = [];
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bell button title equals aria-label when unreadCount is 3', async () => {
|
||||||
|
mockNotificationList.value = [
|
||||||
|
makeNotification({ id: 'n1' }),
|
||||||
|
makeNotification({ id: 'n2' }),
|
||||||
|
makeNotification({ id: 'n3' })
|
||||||
|
];
|
||||||
|
render(NotificationBell);
|
||||||
|
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('NotificationBell', () => {
|
describe('NotificationBell', () => {
|
||||||
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
||||||
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
||||||
|
|||||||
169
frontend/src/lib/components/Pagination.svelte
Normal file
169
frontend/src/lib/components/Pagination.svelte
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<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`;
|
||||||
|
const activePageBase =
|
||||||
|
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm border border-brand-navy bg-brand-navy px-4 py-2 font-sans text-sm font-bold text-white';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the sliding window of 1-indexed page numbers to render as buttons.
|
||||||
|
* Always shows: first, last, current, one neighbor each side.
|
||||||
|
* null entries represent ellipsis gaps.
|
||||||
|
*/
|
||||||
|
const pageWindow = $derived.by(() => {
|
||||||
|
const first = 1;
|
||||||
|
const last = totalPages;
|
||||||
|
const current = page + 1; // convert to 1-indexed
|
||||||
|
|
||||||
|
const windowStart = Math.max(first, current - 1);
|
||||||
|
const windowEnd = Math.min(last, current + 1);
|
||||||
|
|
||||||
|
const result: (number | null)[] = [];
|
||||||
|
|
||||||
|
result.push(first);
|
||||||
|
|
||||||
|
if (windowStart > first + 2) {
|
||||||
|
result.push(null); // left ellipsis
|
||||||
|
} else if (windowStart === first + 2) {
|
||||||
|
result.push(first + 1); // bridge: one page gap, show directly instead of ellipsis
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let p = Math.max(windowStart, first + 1); p <= Math.min(windowEnd, last - 1); p++) {
|
||||||
|
result.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowEnd < last - 2) {
|
||||||
|
result.push(null); // right ellipsis
|
||||||
|
} else if (windowEnd === last - 2) {
|
||||||
|
result.push(last - 1); // bridge: one page gap, show directly instead of ellipsis
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last > first) {
|
||||||
|
result.push(last);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
</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}
|
||||||
|
|
||||||
|
<!-- Mobile: "Seite X von Y" label (hidden on sm: and above) -->
|
||||||
|
<!-- aria-hidden: decorative visual label; AT uses the sr-only span below for aria-current -->
|
||||||
|
<span
|
||||||
|
data-testid="pagination-page-label"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="font-sans text-sm text-ink-2 sm:hidden"
|
||||||
|
>
|
||||||
|
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||||
|
</span>
|
||||||
|
<!-- Always in the AT tree: announces current page regardless of breakpoint.
|
||||||
|
On mobile, the desktop button container is display:none so this is the only AT anchor.
|
||||||
|
On desktop, the active page button also carries aria-current — both announce the same info. -->
|
||||||
|
<span data-testid="pagination-current-page-sr" aria-current="page" class="sr-only">
|
||||||
|
{m.pagination_page_of({ page: page + 1, total: totalPages })}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Desktop: numbered page buttons (hidden below sm:) -->
|
||||||
|
<div data-testid="pagination-pages" class="hidden items-center gap-1 sm:flex">
|
||||||
|
{#each pageWindow as entry, i (entry === null ? 'ellipsis-' + i : entry)}
|
||||||
|
{#if entry === null}
|
||||||
|
{#if i === 1}
|
||||||
|
<span
|
||||||
|
data-testid="pagination-ellipsis-left"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="px-2 text-sm text-ink-2">…</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
data-testid="pagination-ellipsis-right"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="px-2 text-sm text-ink-2">…</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{:else if entry === page + 1}
|
||||||
|
<span
|
||||||
|
data-testid="pagination-page-{entry}"
|
||||||
|
aria-current="page"
|
||||||
|
aria-label={m.pagination_page_button({ page: entry })}
|
||||||
|
class={activePageBase}
|
||||||
|
>
|
||||||
|
{entry}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
data-testid="pagination-page-{entry}"
|
||||||
|
aria-label={m.pagination_page_button({ page: entry })}
|
||||||
|
href={makeHref(entry - 1)}
|
||||||
|
class={linkBase}
|
||||||
|
>
|
||||||
|
{entry}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#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}
|
||||||
220
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
220
frontend/src/lib/components/Pagination.svelte.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
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('mobile page label is aria-hidden (desktop buttons carry the aria-current role)', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 3, makeHref });
|
||||||
|
|
||||||
|
const label = page.getByTestId('pagination-page-label');
|
||||||
|
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('page number buttons', () => {
|
||||||
|
it('renders page number buttons when totalPages > 1', async () => {
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
// active page button — the current page (5, 1-indexed)
|
||||||
|
const activeBtn = nav.getByTestId('pagination-page-5');
|
||||||
|
await expect.element(activeBtn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render page number buttons when totalPages <= 1', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 1, makeHref });
|
||||||
|
|
||||||
|
// entire nav is hidden
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
await expect.element(nav).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the active page button with aria-current="page"', async () => {
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const activeBtn = nav.getByTestId('pagination-page-5');
|
||||||
|
await expect.element(activeBtn).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('active page button has brand-navy background', async () => {
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const activeBtn = nav.getByTestId('pagination-page-5');
|
||||||
|
await expect.element(activeBtn).toHaveClass(/bg-brand-navy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('active page button has 44px touch target', async () => {
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const activeBtn = nav.getByTestId('pagination-page-5');
|
||||||
|
await expect.element(activeBtn).toHaveClass(/min-h-\[44px\]/);
|
||||||
|
await expect.element(activeBtn).toHaveClass(/min-w-\[44px\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inactive page buttons link to their target page via makeHref', async () => {
|
||||||
|
const spy = vi.fn(makeHref);
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref: spy });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
// page button for page 1 (0-indexed: 0) should link to /documents?page=0
|
||||||
|
const firstPageBtn = nav.getByTestId('pagination-page-1');
|
||||||
|
await expect.element(firstPageBtn).toHaveAttribute('href', '/documents?page=0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders first and last page buttons always visible', async () => {
|
||||||
|
render(Pagination, { page: 5, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
|
||||||
|
await expect.element(nav.getByTestId('pagination-page-12')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ellipsis span between first page and window when gap exists', async () => {
|
||||||
|
// page 6 (0-indexed: 5) — window is 5,6,7 — gap between 1 and 5
|
||||||
|
render(Pagination, { page: 5, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const ellipses = nav.getByTestId('pagination-ellipsis-left');
|
||||||
|
await expect.element(ellipses).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ellipsis span between window and last page when gap exists', async () => {
|
||||||
|
// page 1 (0-indexed: 0) — window is 1,2 — gap between 2 and 12
|
||||||
|
render(Pagination, { page: 0, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const ellipsis = nav.getByTestId('pagination-ellipsis-right');
|
||||||
|
await expect.element(ellipsis).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render left ellipsis when window is adjacent to first page', async () => {
|
||||||
|
// page 1 (0-indexed: 0) — window starts at 1, adjacent to first page
|
||||||
|
render(Pagination, { page: 0, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const leftEllipsis = nav.getByTestId('pagination-ellipsis-left');
|
||||||
|
await expect.element(leftEllipsis).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render right ellipsis when window is adjacent to last page', async () => {
|
||||||
|
// last page (0-indexed: 11) — window ends at 12, adjacent to last page
|
||||||
|
render(Pagination, { page: 11, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const rightEllipsis = nav.getByTestId('pagination-ellipsis-right');
|
||||||
|
await expect.element(rightEllipsis).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('page buttons container has hidden class on mobile (sm: prefix)', async () => {
|
||||||
|
// The page buttons container must be hidden below sm: breakpoint
|
||||||
|
render(Pagination, { page: 4, totalPages: 12, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const pageButtons = nav.getByTestId('pagination-pages');
|
||||||
|
await expect.element(pageButtons).toHaveClass(/hidden/);
|
||||||
|
await expect.element(pageButtons).toHaveClass(/sm:flex/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders both pages without ellipsis when totalPages is 2', async () => {
|
||||||
|
render(Pagination, { page: 0, totalPages: 2, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument();
|
||||||
|
await expect.element(nav.getByTestId('pagination-page-2')).toBeInTheDocument();
|
||||||
|
await expect.element(nav.getByTestId('pagination-ellipsis-left')).not.toBeInTheDocument();
|
||||||
|
await expect.element(nav.getByTestId('pagination-ellipsis-right')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mobile page label is aria-hidden so screen readers skip it on wide screens', async () => {
|
||||||
|
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
||||||
|
|
||||||
|
const label = page.getByTestId('pagination-page-label');
|
||||||
|
await expect.element(label).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sr-only span always provides aria-current="page" for screen readers at all breakpoints', async () => {
|
||||||
|
render(Pagination, { page: 2, totalPages: 10, makeHref });
|
||||||
|
|
||||||
|
const nav = page.getByRole('navigation');
|
||||||
|
const srLabel = nav.getByTestId('pagination-current-page-sr');
|
||||||
|
await expect.element(srLabel).toBeInTheDocument();
|
||||||
|
await expect.element(srLabel).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\]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,7 +91,7 @@ let {
|
|||||||
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||||
? 'text-ink-2 hover:bg-surface/10'
|
? 'text-ink-2 hover:bg-surface/10'
|
||||||
: 'bg-surface/10 text-accent'}"
|
: 'bg-surface/10 text-primary'}"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-3.5 w-3.5 shrink-0"
|
class="h-3.5 w-3.5 shrink-0"
|
||||||
|
|||||||
67
frontend/src/lib/components/PdfControls.svelte.spec.ts
Normal file
67
frontend/src/lib/components/PdfControls.svelte.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { vi, describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import PdfControls from './PdfControls.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 3,
|
||||||
|
isLoaded: true,
|
||||||
|
showAnnotations: false,
|
||||||
|
annotationCount: 0,
|
||||||
|
onPrev: vi.fn(),
|
||||||
|
onNext: vi.fn(),
|
||||||
|
onZoomIn: vi.fn(),
|
||||||
|
onZoomOut: vi.fn(),
|
||||||
|
onToggleAnnotations: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PdfControls — annotation toggle visibility', () => {
|
||||||
|
it('renders annotation toggle when annotationCount is greater than zero', async () => {
|
||||||
|
render(PdfControls, { ...defaultProps, annotationCount: 3 });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /annotierungen anzeigen/i }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render annotation toggle when annotationCount is zero', async () => {
|
||||||
|
render(PdfControls, { ...defaultProps, annotationCount: 0 });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /annotierungen/i }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PdfControls — annotation toggle label', () => {
|
||||||
|
it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => {
|
||||||
|
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false });
|
||||||
|
const btn = page.getByRole('button', { name: /annotierungen anzeigen/i });
|
||||||
|
await expect.element(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Annotierungen verbergen" label when annotations are visible', async () => {
|
||||||
|
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true });
|
||||||
|
const btn = page.getByRole('button', { name: /annotierungen verbergen/i });
|
||||||
|
await expect.element(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
|
||||||
|
it('uses text-primary class on annotation toggle button when annotations are hidden', async () => {
|
||||||
|
const { container } = render(PdfControls, {
|
||||||
|
...defaultProps,
|
||||||
|
annotationCount: 2,
|
||||||
|
showAnnotations: false
|
||||||
|
});
|
||||||
|
const allButtons = container.querySelectorAll('button');
|
||||||
|
const annotationBtn = Array.from(allButtons).find((b) =>
|
||||||
|
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||||
|
);
|
||||||
|
expect(annotationBtn).not.toBeNull();
|
||||||
|
expect(annotationBtn!.className).toContain('text-primary');
|
||||||
|
expect(annotationBtn!.className).not.toContain('text-accent');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ let {
|
|||||||
activeAnnotationId = $bindable<string | null>(null),
|
activeAnnotationId = $bindable<string | null>(null),
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onTranscriptionDraw,
|
onTranscriptionDraw,
|
||||||
|
onDeleteAnnotationRequest,
|
||||||
documentFileHash,
|
documentFileHash,
|
||||||
annotationsDimmed = false,
|
annotationsDimmed = false,
|
||||||
flashAnnotationId = null
|
flashAnnotationId = null
|
||||||
@@ -30,6 +31,7 @@ let {
|
|||||||
activeAnnotationId?: string | null;
|
activeAnnotationId?: string | null;
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||||
|
onDeleteAnnotationRequest?: (annotationId: string) => void;
|
||||||
documentFileHash?: string | null;
|
documentFileHash?: string | null;
|
||||||
annotationsDimmed?: boolean;
|
annotationsDimmed?: boolean;
|
||||||
flashAnnotationId?: string | null;
|
flashAnnotationId?: string | null;
|
||||||
@@ -264,6 +266,7 @@ function handleAnnotationClick(id: string) {
|
|||||||
flashAnnotationId={flashAnnotationId}
|
flashAnnotationId={flashAnnotationId}
|
||||||
onDraw={handleDraw}
|
onDraw={handleDraw}
|
||||||
onAnnotationClick={handleAnnotationClick}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
|
onDeleteRequest={onDeleteAnnotationRequest}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function removePerson(id: string | undefined) {
|
|||||||
|
|
||||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||||
<div
|
<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)}
|
{#each selectedPersons as person (person.id)}
|
||||||
<span
|
<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 { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
||||||
|
import FieldLabelBadge from './document/FieldLabelBadge.svelte';
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,6 +19,7 @@ interface Props {
|
|||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
restrictToCorrespondentsOf?: string;
|
restrictToCorrespondentsOf?: string;
|
||||||
|
badge?: 'additive' | 'replace';
|
||||||
onchange?: (value: string) => void;
|
onchange?: (value: string) => void;
|
||||||
onfocused?: () => void;
|
onfocused?: () => void;
|
||||||
}
|
}
|
||||||
@@ -34,6 +36,7 @@ let {
|
|||||||
autofocus = false,
|
autofocus = false,
|
||||||
required = false,
|
required = false,
|
||||||
restrictToCorrespondentsOf,
|
restrictToCorrespondentsOf,
|
||||||
|
badge,
|
||||||
onchange,
|
onchange,
|
||||||
onfocused
|
onfocused
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -116,7 +119,7 @@ function selectPerson(person: Person) {
|
|||||||
class={compact
|
class={compact
|
||||||
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
|
||||||
: 'block text-sm font-medium text-ink-2'}
|
: '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} />
|
<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'
|
? '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
|
: 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 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)}
|
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ let { percentage }: { percentage: number } = $props();
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-accent' : 'text-gray-400'}"
|
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-primary' : 'text-gray-400'}"
|
||||||
>
|
>
|
||||||
{percentage}%
|
{percentage}%
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ describe('ProgressRing', () => {
|
|||||||
expect(el.className).toContain('text-gray-400');
|
expect(el.className).toContain('text-gray-400');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a mint-colored label when percentage is > 0', async () => {
|
it('renders a primary-colored label when percentage is > 0', async () => {
|
||||||
render(ProgressRing, { percentage: 75 });
|
render(ProgressRing, { percentage: 75 });
|
||||||
const label = page.getByText('75%');
|
const label = page.getByText('75%');
|
||||||
await expect.element(label).toBeInTheDocument();
|
await expect.element(label).toBeInTheDocument();
|
||||||
const el = (await label.element()) as HTMLElement;
|
const el = (await label.element()) as HTMLElement;
|
||||||
expect(el.className).toContain('text-accent');
|
expect(el.className).toContain('text-primary');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a fully filled arc for 100%', async () => {
|
it('renders a fully filled arc for 100%', async () => {
|
||||||
|
|||||||
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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark';
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ onMount(() => {
|
|||||||
theme = resolveInitialTheme();
|
theme = resolveInitialTheme();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const themeLabel = $derived(
|
||||||
|
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
|
||||||
|
);
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
theme = theme === 'dark' ? 'light' : 'dark';
|
theme = theme === 'dark' ? 'light' : 'dark';
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
@@ -29,8 +34,8 @@ function toggle() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggle}
|
onclick={toggle}
|
||||||
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
aria-label={themeLabel}
|
||||||
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
title={themeLabel}
|
||||||
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
{#if theme === 'dark'}
|
{#if theme === 'dark'}
|
||||||
|
|||||||
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
45
frontend/src/lib/components/ThemeToggle.svelte.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import ThemeToggle from './ThemeToggle.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.removeItem('theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle — label derivation (light mode)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label invites switching to dark mode when theme is light', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title equals aria-label in light mode', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeToggle — label derivation (dark mode)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aria-label invites switching to light mode when theme is dark', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title equals aria-label in dark mode', async () => {
|
||||||
|
render(ThemeToggle);
|
||||||
|
const btn = await page.getByRole('button').element();
|
||||||
|
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
|
||||||
|
});
|
||||||
|
});
|
||||||
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 { m } from '$lib/paraglide/messages.js';
|
||||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||||
import OcrTrigger from './OcrTrigger.svelte';
|
import OcrTrigger from './OcrTrigger.svelte';
|
||||||
|
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
||||||
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
||||||
@@ -18,6 +19,7 @@ type Props = {
|
|||||||
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
||||||
onDeleteBlock: (blockId: string) => Promise<void>;
|
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||||
onReviewToggle: (blockId: string) => Promise<void>;
|
onReviewToggle: (blockId: string) => Promise<void>;
|
||||||
|
onMarkAllReviewed?: () => Promise<void>;
|
||||||
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
|
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
|
||||||
canWrite?: boolean;
|
canWrite?: boolean;
|
||||||
trainingLabels?: string[];
|
trainingLabels?: string[];
|
||||||
@@ -36,6 +38,7 @@ let {
|
|||||||
onSaveBlock,
|
onSaveBlock,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
onReviewToggle,
|
onReviewToggle,
|
||||||
|
onMarkAllReviewed,
|
||||||
onTriggerOcr,
|
onTriggerOcr,
|
||||||
canWrite = false,
|
canWrite = false,
|
||||||
trainingLabels = [],
|
trainingLabels = [],
|
||||||
@@ -45,12 +48,14 @@ let {
|
|||||||
let activeBlockId: string | null = $state(null);
|
let activeBlockId: string | null = $state(null);
|
||||||
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
||||||
let listEl: HTMLElement | null = $state(null);
|
let listEl: HTMLElement | null = $state(null);
|
||||||
|
let markingAllReviewed = $state(false);
|
||||||
|
|
||||||
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||||
const hasBlocks = $derived(blocks.length > 0);
|
const hasBlocks = $derived(blocks.length > 0);
|
||||||
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
||||||
const totalCount = $derived(blocks.length);
|
const totalCount = $derived(blocks.length);
|
||||||
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
||||||
|
const allReviewed = $derived(totalCount > 0 && reviewedCount === totalCount);
|
||||||
|
|
||||||
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -59,6 +64,16 @@ $effect(() => {
|
|||||||
if (block) activeBlockId = block.id;
|
if (block) activeBlockId = block.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function handleMarkAllReviewed() {
|
||||||
|
if (!onMarkAllReviewed) return;
|
||||||
|
markingAllReviewed = true;
|
||||||
|
try {
|
||||||
|
await onMarkAllReviewed();
|
||||||
|
} finally {
|
||||||
|
markingAllReviewed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
|
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
|
||||||
|
|
||||||
const dragDrop = createBlockDragDrop({
|
const dragDrop = createBlockDragDrop({
|
||||||
@@ -146,9 +161,56 @@ async function handleLabelToggle(label: string) {
|
|||||||
{#if hasBlocks}
|
{#if hasBlocks}
|
||||||
<!-- Sticky review progress header -->
|
<!-- Sticky review progress header -->
|
||||||
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
|
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
|
||||||
<p class="font-sans text-xs text-ink-2">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
|
<p class="font-sans text-xs text-ink-2">
|
||||||
</p>
|
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
|
||||||
|
</p>
|
||||||
|
{#if onMarkAllReviewed}
|
||||||
|
<button
|
||||||
|
onclick={handleMarkAllReviewed}
|
||||||
|
disabled={allReviewed || markingAllReviewed}
|
||||||
|
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
|
||||||
|
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{#if markingAllReviewed}
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Alle als fertig markieren
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
|
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full bg-brand-mint transition-all duration-300"
|
class="h-full rounded-full bg-brand-mint transition-all duration-300"
|
||||||
@@ -231,28 +293,12 @@ async function handleLabelToggle(label: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
<div class="p-4">
|
||||||
<svg
|
<TranscribeCoachEmptyState />
|
||||||
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canWrite}
|
{#if canWrite && hasBlocks}
|
||||||
<div class="border-t border-line px-4 py-3">
|
<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>
|
<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">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ function renderView(overrides: Record<string, unknown> = {}, service = createCon
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unreviewedBlock1 = { ...block1, reviewed: false };
|
||||||
|
const unreviewedBlock2 = { ...block2, reviewed: false };
|
||||||
|
const reviewedBlock1 = { ...block1, reviewed: true };
|
||||||
|
const reviewedBlock2 = { ...block2, reviewed: true };
|
||||||
|
|
||||||
describe('TranscriptionEditView — rendering', () => {
|
describe('TranscriptionEditView — rendering', () => {
|
||||||
it('renders blocks in sort order', async () => {
|
it('renders blocks in sort order', async () => {
|
||||||
renderView();
|
renderView();
|
||||||
@@ -61,9 +66,21 @@ describe('TranscriptionEditView — rendering', () => {
|
|||||||
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
|
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: [] });
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,3 +274,61 @@ describe('TranscriptionEditView — review progress counter', () => {
|
|||||||
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
|
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Bulk mark all as reviewed ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('TranscriptionEditView — mark all reviewed', () => {
|
||||||
|
it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => {
|
||||||
|
renderView({
|
||||||
|
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||||
|
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
|
||||||
|
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables button when all blocks are already reviewed', async () => {
|
||||||
|
renderView({
|
||||||
|
blocks: [reviewedBlock1, reviewedBlock2],
|
||||||
|
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||||
|
.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
|
||||||
|
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
|
||||||
|
renderView({
|
||||||
|
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||||
|
onMarkAllReviewed
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
|
||||||
|
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables button while operation is in-flight', async () => {
|
||||||
|
let resolveMarkAll!: () => void;
|
||||||
|
const onMarkAllReviewed = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(new Promise<void>((r) => (resolveMarkAll = r)));
|
||||||
|
renderView({
|
||||||
|
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||||
|
onMarkAllReviewed
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
|
||||||
|
await btn.click();
|
||||||
|
await expect.element(btn).toBeDisabled();
|
||||||
|
resolveMarkAll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||||
|
import HelpPopover from './HelpPopover.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: 'read' | 'edit';
|
mode: 'read' | 'edit';
|
||||||
@@ -33,31 +34,36 @@ function handleReadClick() {
|
|||||||
<div
|
<div
|
||||||
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
||||||
>
|
>
|
||||||
<!-- Segmented toggle -->
|
<!-- Segmented toggle + help chip -->
|
||||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
<div class="flex items-center gap-1.5">
|
||||||
<button
|
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||||
type="button"
|
<button
|
||||||
data-testid="mode-read"
|
type="button"
|
||||||
aria-disabled={!hasBlocks}
|
data-testid="mode-read"
|
||||||
onclick={handleReadClick}
|
aria-disabled={!hasBlocks}
|
||||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
onclick={handleReadClick}
|
||||||
|
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||||
? 'bg-primary text-primary-fg'
|
? 'bg-primary text-primary-fg'
|
||||||
: 'text-ink-2 hover:text-ink'}"
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||||
>
|
>
|
||||||
{m.mode_read()}
|
{m.mode_read()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="mode-edit"
|
data-testid="mode-edit"
|
||||||
onclick={() => onModeChange('edit')}
|
onclick={() => onModeChange('edit')}
|
||||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||||
? 'bg-primary text-primary-fg'
|
? 'bg-primary text-primary-fg'
|
||||||
: 'text-ink-2 hover:text-ink'}"
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
>
|
>
|
||||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
<HelpPopover label={m.transcription_mode_help_label()}>
|
||||||
|
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
|
||||||
|
</HelpPopover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status line (hidden on mobile to save space) -->
|
<!-- Status line (hidden on mobile to save space) -->
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
|
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('TranscriptionPanelHeader', () => {
|
describe('TranscriptionPanelHeader', () => {
|
||||||
it('should render Lesen and Bearbeiten buttons', async () => {
|
it('should render Lesen and Bearbeiten buttons', async () => {
|
||||||
render(TranscriptionPanelHeader, {
|
render(TranscriptionPanelHeader, {
|
||||||
@@ -148,4 +150,33 @@ describe('TranscriptionPanelHeader', () => {
|
|||||||
expect(statusText).not.toBeNull();
|
expect(statusText).not.toBeNull();
|
||||||
expect(statusText!.textContent).toContain('2026');
|
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">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
|
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
|
||||||
|
import FieldLabelBadge from './FieldLabelBadge.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tags = $bindable<Tag[]>([]),
|
tags = $bindable<Tag[]>([]),
|
||||||
currentTitle = $bindable(''),
|
currentTitle = $bindable(''),
|
||||||
|
documentLocation = $bindable(''),
|
||||||
|
archiveBox = $bindable(''),
|
||||||
|
archiveFolder = $bindable(''),
|
||||||
initialTitle = '',
|
initialTitle = '',
|
||||||
initialDocumentLocation = '',
|
initialArchiveBox = '',
|
||||||
|
initialArchiveFolder = '',
|
||||||
initialSummary = '',
|
initialSummary = '',
|
||||||
titleRequired = false,
|
titleRequired = false,
|
||||||
suggestedTitle = '',
|
suggestedTitle = '',
|
||||||
hideTitle = false
|
hideTitle = false,
|
||||||
|
editMode = false
|
||||||
}: {
|
}: {
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
currentTitle?: string;
|
currentTitle?: string;
|
||||||
|
documentLocation?: string;
|
||||||
|
archiveBox?: string;
|
||||||
|
archiveFolder?: string;
|
||||||
initialTitle?: string;
|
initialTitle?: string;
|
||||||
initialDocumentLocation?: string;
|
initialArchiveBox?: string;
|
||||||
|
initialArchiveFolder?: string;
|
||||||
initialSummary?: string;
|
initialSummary?: string;
|
||||||
titleRequired?: boolean;
|
titleRequired?: boolean;
|
||||||
suggestedTitle?: string;
|
suggestedTitle?: string;
|
||||||
hideTitle?: boolean;
|
hideTitle?: boolean;
|
||||||
|
editMode?: boolean;
|
||||||
} = $props();
|
} = $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);
|
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);
|
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -67,40 +88,61 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
|
|||||||
|
|
||||||
<!-- Schlagworte (optional) -->
|
<!-- Schlagworte (optional) -->
|
||||||
<div>
|
<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} />
|
<TagInput bind:tags={tags} />
|
||||||
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
|
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inhalt (optional) -->
|
{#if !editMode}
|
||||||
<div>
|
<!-- Inhalt (optional) — not bulk-editable. -->
|
||||||
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
<div>
|
||||||
>{m.form_label_content()}</label
|
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
|
||||||
>
|
>{m.form_label_content()}</label
|
||||||
<textarea
|
>
|
||||||
id="summary"
|
<textarea
|
||||||
name="summary"
|
id="summary"
|
||||||
rows="5"
|
name="summary"
|
||||||
placeholder={m.form_placeholder_content()}
|
rows="5"
|
||||||
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"
|
placeholder={m.form_placeholder_content()}
|
||||||
>{initialSummary}</textarea
|
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>
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Aufbewahrungsort (optional) -->
|
<!-- Karton -->
|
||||||
<div>
|
<div data-testid="description-archive-box">
|
||||||
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
|
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
>{m.form_label_archive_location()}</label
|
{m.form_label_archive_box()}
|
||||||
>
|
{#if editMode}<FieldLabelBadge variant="replace" />{/if}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="documentLocation"
|
id="archiveBox"
|
||||||
type="text"
|
type="text"
|
||||||
name="documentLocation"
|
name="archiveBox"
|
||||||
value={initialDocumentLocation}
|
bind:value={archiveBox}
|
||||||
placeholder={m.form_placeholder_archive_location()}
|
|
||||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
</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:tags={tags}
|
||||||
bind:currentTitle={currentTitle}
|
bind:currentTitle={currentTitle}
|
||||||
initialTitle={doc.title ?? ''}
|
initialTitle={doc.title ?? ''}
|
||||||
initialDocumentLocation={doc.documentLocation ?? ''}
|
initialArchiveBox={doc.archiveBox ?? ''}
|
||||||
|
initialArchiveFolder={doc.archiveFolder ?? ''}
|
||||||
initialSummary={doc.summary ?? ''}
|
initialSummary={doc.summary ?? ''}
|
||||||
titleRequired={true}
|
titleRequired={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user