diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index ea943163..20717feb 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller; import java.io.IOException; import java.time.LocalDate; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -13,12 +14,18 @@ import java.util.UUID; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.validation.annotation.Validated; +import org.raddatz.familienarchiv.dto.BatchMetadataRequest; +import org.raddatz.familienarchiv.dto.BulkEditError; +import org.raddatz.familienarchiv.dto.BulkEditResult; import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO; +import org.raddatz.familienarchiv.dto.DocumentBatchSummary; +import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.TagOperator; @@ -237,6 +244,100 @@ public class DocumentController { 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 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 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 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 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 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 batchMetadata(@RequestBody @Valid BatchMetadataRequest request, Authentication authentication) { + if (request == null || request.ids() == null || request.ids().isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required"); + } + if (request.ids().size() > BULK_EDIT_MAX_IDS) { + throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS, + "Maximum " + BULK_EDIT_MAX_IDS + " ids per request, got: " + request.ids().size()); + } + UUID actorId = requireUserId(authentication); + log.info("batchMetadata actor={} ids={}", actorId, request.ids().size()); + return documentService.batchMetadata(request.ids()); + } + @GetMapping("/incomplete-count") @RequirePermission(Permission.WRITE_ALL) public Map getIncompleteCount() { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/BatchMetadataRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/BatchMetadataRequest.java new file mode 100644 index 00000000..47bc489b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/BatchMetadataRequest.java @@ -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 ids) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditError.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditError.java new file mode 100644 index 00000000..2bde3fdd --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditError.java @@ -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) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditResult.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditResult.java new file mode 100644 index 00000000..af8608b1 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/BulkEditResult.java @@ -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 errors) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchSummary.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchSummary.java new file mode 100644 index 00000000..aaea14a0 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBatchSummary.java @@ -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) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBulkEditDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBulkEditDTO.java new file mode 100644 index 00000000..f7ec4a42 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/DocumentBulkEditDTO.java @@ -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: + *
    + *
  • {@code tagNames} and {@code receiverIds} are additive — + * merged into each document's existing set, never replacing it.
  • + *
  • {@code senderId}, {@code documentLocation}, {@code archiveBox}, + * {@code archiveFolder} are replace-on-non-blank — null/blank + * fields are skipped, anything else overwrites.
  • + *
+ * + *

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}) are records because they have no + * test-side mutation. Tracked in the cycle-1 review for follow-up. + * + *

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 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 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; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 686c8627..187b793d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -111,6 +111,8 @@ public enum ErrorCode { VALIDATION_ERROR, /** Batch upload exceeds the maximum allowed file count per request. 400 */ BATCH_TOO_LARGE, + /** Bulk edit request exceeds the per-request document ID cap. 400 */ + BULK_EDIT_TOO_MANY_IDS, /** An unexpected server-side error occurred. 500 */ INTERNAL_ERROR, } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index b7d50f99..3fd9e43a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -8,6 +8,8 @@ import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO; +import org.raddatz.familienarchiv.dto.DocumentBatchSummary; +import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO; import org.raddatz.familienarchiv.dto.DocumentSearchItem; import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSort; @@ -334,20 +336,143 @@ public class DocumentService { public Document updateDocumentTags(UUID docId, List tagNames) { Document doc = documentRepository.findById(docId) .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId)); + doc.setTags(resolveTags(tagNames)); + return documentRepository.save(doc); + } - Set 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 resolveTags(List tagNames) { + if (tagNames == null || tagNames.isEmpty()) return new HashSet<>(); + Set resolved = new HashSet<>(); for (String name : tagNames) { - // Clean the string String cleanName = name.trim(); - if (cleanName.isEmpty()) - continue; + if (cleanName.isEmpty()) continue; + resolved.add(tagService.findOrCreate(cleanName)); + } + return resolved; + } - newTags.add(tagService.findOrCreate(cleanName)); + /** + * Returns all document IDs matching the given filter parameters, ignoring + * pagination. Used by the bulk-edit "Alle X editieren" fast path so the + * frontend can replace the selection with every match across pages in one + * round-trip. + */ + @Transactional(readOnly = true) + public List findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, + List tags, String tagQ, DocumentStatus status, TagOperator tagOperator) { + boolean hasText = StringUtils.hasText(text); + List rankedIds = null; + if (hasText) { + rankedIds = documentRepository.findRankedIdsByFts(text); + if (rankedIds.isEmpty()) return List.of(); } - doc.setTags(newTags); - return documentRepository.save(doc); + Specification spec = buildSearchSpec( + hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator); + return documentRepository.findAll(spec).stream().map(Document::getId).toList(); + } + + /** + * Single source of truth for the search Specification chain. Shared by + * {@link #searchDocuments} (paged + sorted) and {@link #findIdsForFilter} + * (uncapped, ID-only). Caller does its own FTS short-circuit when the + * full-text query returned no rows. + */ + private Specification buildSearchSpec(boolean hasText, List ftsIds, + LocalDate from, LocalDate to, + UUID sender, UUID receiver, + List tags, String tagQ, + DocumentStatus status, TagOperator tagOperator) { + boolean useOrLogic = tagOperator == TagOperator.OR; + List> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags); + Specification 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 batchMetadata(List 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 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 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; } /** @@ -413,17 +538,8 @@ public class DocumentService { if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of()); } - boolean useOrLogic = tagOperator == TagOperator.OR; - List> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags); - - Specification textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null; - Specification spec = Specification.where(textSpec) - .and(isBetween(from, to)) - .and(hasSender(sender)) - .and(hasReceiver(receiver)) - .and(hasTags(expandedTagSets, useOrLogic)) - .and(hasTagPartial(tagQ)) - .and(hasStatus(status)); + Specification spec = buildSearchSpec( + hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator); // SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory. // JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index b80ed173..cbf2ece6 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -929,4 +929,315 @@ class DocumentControllerTest { .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 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"))); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index 4c34862b..1bea2233 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -16,6 +16,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.dto.MatchOffset; import org.raddatz.familienarchiv.dto.SearchMatchData; +import org.raddatz.familienarchiv.dto.TagOperator; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; @@ -1917,4 +1918,333 @@ class DocumentServiceTest { .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 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 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"); + } } diff --git a/frontend/e2e/bulk-edit.spec.ts b/frontend/e2e/bulk-edit.spec.ts new file mode 100644 index 00000000..511ccd09 --- /dev/null +++ b/frontend/e2e/bulk-edit.spec.ts @@ -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(); + }); +}); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index fe3ecb42..0384e776 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -873,5 +873,30 @@ "bulk_drop_zone_label": "Dateien ablegen", "bulk_remove_file": "Entfernen", "bulk_title_single": "Neues Dokument", - "bulk_title_multi": "Neue Dokumente" + "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" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1699a911..5e75ef71 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -873,5 +873,30 @@ "bulk_drop_zone_label": "Drop files here", "bulk_remove_file": "Remove", "bulk_title_single": "New Document", - "bulk_title_multi": "New Documents" + "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" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 61fba34c..12c2c5c9 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -873,5 +873,30 @@ "bulk_drop_zone_label": "Soltar archivos aquí", "bulk_remove_file": "Eliminar", "bulk_title_single": "Nuevo Documento", - "bulk_title_multi": "Nuevos Documentos" + "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}" } diff --git a/frontend/src/lib/components/DocumentRow.svelte b/frontend/src/lib/components/DocumentRow.svelte index d4e2bd5c..2b656a65 100644 --- a/frontend/src/lib/components/DocumentRow.svelte +++ b/frontend/src/lib/components/DocumentRow.svelte @@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api'; import { applyOffsets } from '$lib/search'; import { formatDate } from '$lib/utils/date'; import * as m from '$lib/paraglide/messages.js'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; import ProgressRing from './ProgressRing.svelte'; import ContributorStack from './ContributorStack.svelte'; import DocumentThumbnail from './DocumentThumbnail.svelte'; type DocumentSearchItem = components['schemas']['DocumentSearchItem']; -let { item }: { item: DocumentSearchItem } = $props(); +let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props(); const doc = $derived(item.document); const titleText = $derived(doc.title || doc.originalFilename); @@ -55,6 +56,21 @@ function safeTagColor(color: string | null | undefined): string {

+ + {#if canWrite} + + {/if} diff --git a/frontend/src/lib/components/DocumentRow.svelte.spec.ts b/frontend/src/lib/components/DocumentRow.svelte.spec.ts index 274062a6..f7d0b92a 100644 --- a/frontend/src/lib/components/DocumentRow.svelte.spec.ts +++ b/frontend/src/lib/components/DocumentRow.svelte.spec.ts @@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import { goto } from '$app/navigation'; import DocumentRow from './DocumentRow.svelte'; +import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte'; import type { components } from '$lib/generated/api'; vi.mock('$app/navigation', () => ({ goto: vi.fn() })); @@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() })); afterEach(() => { cleanup(); vi.mocked(goto).mockClear(); + bulkSelectionStore.clear(); }); type DocumentSearchItem = components['schemas']['DocumentSearchItem']; @@ -265,6 +267,45 @@ describe('DocumentRow – tags', () => { }); }); +// ─── Bulk-selection checkbox ───────────────────────────────────────────────── + +describe('DocumentRow – bulk selection checkbox', () => { + it('does not render the checkbox when canWrite is false', async () => { + render(DocumentRow, { item: makeItem(), canWrite: false }); + await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument(); + }); + + it('renders the checkbox when canWrite is true', async () => { + render(DocumentRow, { item: makeItem(), canWrite: true }); + await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument(); + }); + + it('checkbox aria-label includes the document title', async () => { + const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } }); + render(DocumentRow, { item, canWrite: true }); + await expect + .element(page.getByRole('checkbox', { name: /Brief an Anna/i })) + .toBeInTheDocument(); + }); + + it('toggling the checkbox calls bulkSelectionStore.toggle', async () => { + const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } }); + render(DocumentRow, { item, canWrite: true }); + expect(bulkSelectionStore.has('doc-42')).toBe(false); + + document.querySelector('input[type="checkbox"]')?.click(); + + await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true); + }); + + it('checked state mirrors the store', async () => { + bulkSelectionStore.add('doc-99'); + const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } }); + render(DocumentRow, { item, canWrite: true }); + await expect.element(page.getByRole('checkbox')).toBeChecked(); + }); +}); + // ─── ProgressRing & ContributorStack ───────────────────────────────────────── describe('DocumentRow – progress ring and contributors', () => { diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index 815623da..0ac204ab 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -4,6 +4,7 @@ import type { components } from '$lib/generated/api'; import { m } from '$lib/paraglide/messages.js'; import { clickOutside } from '$lib/actions/clickOutside'; import { createTypeahead } from '$lib/hooks/useTypeahead.svelte'; +import FieldLabelBadge from './document/FieldLabelBadge.svelte'; type Person = components['schemas']['Person']; interface Props { @@ -18,6 +19,7 @@ interface Props { autofocus?: boolean; required?: boolean; restrictToCorrespondentsOf?: string; + badge?: 'additive' | 'replace'; onchange?: (value: string) => void; onfocused?: () => void; } @@ -34,6 +36,7 @@ let { autofocus = false, required = false, restrictToCorrespondentsOf, + badge, onchange, onfocused }: Props = $props(); @@ -116,7 +119,7 @@ function selectPerson(person: Person) { class={compact ? 'block text-xs font-bold tracking-wide text-ink-3 uppercase' : 'block text-sm font-medium text-ink-2'} - >{label}{#if required}*{/if}{label}{#if required}*{/if}{#if badge}{/if} diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte index 6ce93a07..12ef0aa3 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte @@ -1,10 +1,11 @@
@@ -190,12 +327,20 @@ async function save() { - {isMulti ? m.bulk_title_multi() : m.bulk_title_single()} + {#if mode === 'edit'} + {m.bulk_edit_topbar_title()} + {:else} + {isMulti ? m.bulk_title_multi() : m.bulk_title_single()} + {/if} {#if isMulti} - {m.bulk_count_pill({ count: files.size })} + {#if mode === 'edit'} + {m.bulk_edit_count_pill({ count: files.size })} + {:else} + {m.bulk_count_pill({ count: files.size })} + {/if} +
{/if}
@@ -314,6 +532,7 @@ async function save() { onSave={save} onDiscard={handleDiscard} disabled={saving} + editMode={mode === 'edit'} />
diff --git a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts index 1b24627d..adb0b525 100644 --- a/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts +++ b/frontend/src/lib/components/document/BulkDocumentEditLayout.svelte.spec.ts @@ -312,3 +312,232 @@ describe('BulkDocumentEditLayout', () => { ); }); }); + +// ─── 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 + documentLocation + archiveBox + archiveFolder = 4 + expect(replaceBadges.length).toBeGreaterThanOrEqual(4); + }); + + 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 } + ); + }); +}); diff --git a/frontend/src/lib/components/document/BulkSelectionBar.svelte b/frontend/src/lib/components/document/BulkSelectionBar.svelte new file mode 100644 index 00000000..27f10174 --- /dev/null +++ b/frontend/src/lib/components/document/BulkSelectionBar.svelte @@ -0,0 +1,74 @@ + + + + +{#if visible} +
+
+ + {count === 1 ? m.bulk_edit_n_selected_one() : m.bulk_edit_n_selected_other({ count })} + + +
+
+ + +
+
+{/if} diff --git a/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts b/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts new file mode 100644 index 00000000..523b797e --- /dev/null +++ b/frontend/src/lib/components/document/BulkSelectionBar.svelte.spec.ts @@ -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 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(); + } + }); +}); diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte index 27e7442e..65742bce 100644 --- a/frontend/src/lib/components/document/DescriptionSection.svelte +++ b/frontend/src/lib/components/document/DescriptionSection.svelte @@ -1,30 +1,48 @@ @@ -67,40 +85,80 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
-

{m.form_label_tags()}

+

+ {m.form_label_tags()} + {#if editMode}{/if} +

t.name).join(',')} />
- -
- - -
+ {#if !editMode} + +
+ + +
+ {/if} -
+
+ >{m.form_label_archive_location()} + {#if editMode}{/if} +

{m.form_helper_archive_location()}

+ + {#if editMode} + +
+ + +

{m.form_helper_archive_box()}

+
+ + +
+ + +

{m.form_helper_archive_folder()}

+
+ {/if}
diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte.spec.ts b/frontend/src/lib/components/document/DescriptionSection.svelte.spec.ts new file mode 100644 index 00000000..8d000a8c --- /dev/null +++ b/frontend/src/lib/components/document/DescriptionSection.svelte.spec.ts @@ -0,0 +1,50 @@ +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('pre-fills the documentLocation input from initialDocumentLocation', async () => { + render(DescriptionSection, { initialDocumentLocation: 'Schrank 3, Mappe B' }); + const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement; + expect(locationInput.value).toBe('Schrank 3, Mappe B'); + }); + + it('does not stomp a parent-bound documentLocation that is already non-empty', async () => { + render(DescriptionSection, { + documentLocation: 'Bound Value', + initialDocumentLocation: 'Should Not Win' + }); + const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement; + expect(locationInput.value).toBe('Bound Value'); + }); + + it('renders the editMode-only archiveBox + archiveFolder fields when editMode=true', async () => { + 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('hides the editMode-only archive fields when editMode=false', async () => { + render(DescriptionSection, { editMode: false }); + expect(document.querySelector('[data-testid="description-archive-box"]')).toBeNull(); + expect(document.querySelector('[data-testid="description-archive-folder"]')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/document/FieldLabelBadge.svelte b/frontend/src/lib/components/document/FieldLabelBadge.svelte new file mode 100644 index 00000000..6a694dca --- /dev/null +++ b/frontend/src/lib/components/document/FieldLabelBadge.svelte @@ -0,0 +1,16 @@ + + + + {text} + diff --git a/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts b/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts new file mode 100644 index 00000000..d52213ad --- /dev/null +++ b/frontend/src/lib/components/document/FieldLabelBadge.svelte.spec.ts @@ -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/); + }); +}); diff --git a/frontend/src/lib/components/document/FileSwitcherStrip.svelte b/frontend/src/lib/components/document/FileSwitcherStrip.svelte index 8816d1a1..2ee272da 100644 --- a/frontend/src/lib/components/document/FileSwitcherStrip.svelte +++ b/frontend/src/lib/components/document/FileSwitcherStrip.svelte @@ -4,7 +4,11 @@ import { m } from '$lib/paraglide/messages.js'; export interface FileEntry { id: string; - file: File; + /** Present in upload mode only. Edit mode entries reference an existing + * document by `documentId` and have no local file blob. */ + file?: File; + /** Present in edit mode only — the server-side document UUID being edited. */ + documentId?: string; title: string; status: 'idle' | 'error'; previewUrl: string; diff --git a/frontend/src/lib/components/document/UploadSaveBar.svelte b/frontend/src/lib/components/document/UploadSaveBar.svelte index d404e864..adeca383 100644 --- a/frontend/src/lib/components/document/UploadSaveBar.svelte +++ b/frontend/src/lib/components/document/UploadSaveBar.svelte @@ -6,14 +6,21 @@ let { chunkProgress, onSave, onDiscard, - disabled = false + disabled = false, + editMode = false }: { fileCount: number; chunkProgress?: { done: number; total: number }; onSave: () => void; onDiscard: () => void | Promise; disabled?: boolean; + editMode?: boolean; } = $props(); + +const saveCta = $derived.by(() => { + if (editMode) return m.bulk_edit_save_button(); + return fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount }); +});
@@ -24,9 +31,22 @@ let { aria-valuenow={chunkProgress.done} aria-valuemin={0} aria-valuemax={chunkProgress.total} - aria-label={m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })} - class="[&::-webkit-progress-bar]:bg-brand-sand mb-3 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent" + aria-label={editMode + ? m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total }) + : m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })} + class="[&::-webkit-progress-bar]:bg-brand-sand mb-2 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent" > + {#if editMode && chunkProgress.total > 1} + +

+ {m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })} +

+ {/if} {/if}
diff --git a/frontend/src/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte index 679ecc84..12e47cd2 100644 --- a/frontend/src/lib/components/document/WhoWhenSection.svelte +++ b/frontend/src/lib/components/document/WhoWhenSection.svelte @@ -1,7 +1,8 @@ + + + {m.bulk_edit_title()} – Familienarchiv + + +{#if loading} +
+ + {m.bulk_edit_loading()} +
+{:else if error} + +{:else if entries.length > 0} + +{/if} diff --git a/frontend/src/routes/documents/bulk-edit/page.server.spec.ts b/frontend/src/routes/documents/bulk-edit/page.server.spec.ts new file mode 100644 index 00000000..d43d2248 --- /dev/null +++ b/frontend/src/routes/documents/bulk-edit/page.server.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { load } from './+page.server'; + +describe('/documents/bulk-edit +page.server.ts', () => { + it('redirects to /documents when user lacks WRITE_ALL', async () => { + const locals = { user: { groups: [{ permissions: ['READ_ALL'] }] } }; + try { + // @ts-expect-error — partial event shape sufficient for this guard + await load({ locals }); + throw new Error('expected redirect to be thrown'); + } catch (e) { + const err = e as { status?: number; location?: string }; + expect(err.status).toBe(303); + expect(err.location).toBe('/documents'); + } + }); + + it('redirects when user has no groups', async () => { + const locals = { user: { groups: [] } }; + try { + // @ts-expect-error — partial event shape sufficient for this guard + await load({ locals }); + throw new Error('expected redirect'); + } catch (e) { + expect((e as { status?: number }).status).toBe(303); + } + }); + + it('redirects when no user is logged in', async () => { + const locals = {}; + try { + // @ts-expect-error — partial event shape sufficient for this guard + await load({ locals }); + throw new Error('expected redirect'); + } catch (e) { + expect((e as { status?: number }).status).toBe(303); + } + }); + + it('returns canWrite=true for a WRITE_ALL user', async () => { + const locals = { user: { groups: [{ permissions: ['WRITE_ALL', 'READ_ALL'] }] } }; + // @ts-expect-error — partial event shape sufficient for this guard + const result = await load({ locals }); + expect(result).toEqual({ canWrite: true }); + }); + + it('redirects when a group has no permissions array (defensive)', async () => { + // Sara C7 — a UserGroup row with NULL permissions used to throw on + // .includes(); the guard now treats that case as "not WRITE_ALL". + const locals = { + user: { groups: [{ permissions: undefined as unknown as string[] }] } + }; + try { + // @ts-expect-error — partial event shape sufficient for this guard + await load({ locals }); + throw new Error('expected redirect'); + } catch (e) { + expect((e as { status?: number }).status).toBe(303); + } + }); +}); diff --git a/frontend/src/routes/enrich/+page.server.ts b/frontend/src/routes/enrich/+page.server.ts index 7cb4ea02..65d86b8f 100644 --- a/frontend/src/routes/enrich/+page.server.ts +++ b/frontend/src/routes/enrich/+page.server.ts @@ -19,5 +19,5 @@ export async function load({ const documents = result.response.ok ? (result.data ?? []) : []; - return { documents }; + return { documents, canWrite }; } diff --git a/frontend/src/routes/enrich/+page.svelte b/frontend/src/routes/enrich/+page.svelte index b2130465..ff0a1136 100644 --- a/frontend/src/routes/enrich/+page.svelte +++ b/frontend/src/routes/enrich/+page.svelte @@ -1,14 +1,19 @@ -
+ +
0 && canWrite}> @@ -61,8 +66,24 @@ const count = $derived(documents.length);
    {#each documents as doc (doc.id)} -
  • - +
  • + +
    + {#if canWrite} + + {/if}

    {doc.title} @@ -74,10 +95,12 @@ const count = $derived(documents.length); aria-hidden="true" class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70" /> - +

  • {/each}
{/if}
+ +