Compare commits

..

1 Commits

Author SHA1 Message Date
Marcel
d5e0d2226a chore: merge main into feat/issue-281-documents-page
Some checks failed
CI / Backend Unit Tests (push) Failing after 2m50s
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 08:44:20 +02:00
404 changed files with 4020 additions and 44555 deletions

View File

@@ -311,15 +311,13 @@ Save bar pattern — use **sticky full-bleed** for long forms (edit document), *
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
```
Back button pattern — use the shared `<BackButton>` component from `$lib/components/BackButton.svelte`:
Back link pattern:
```svelte
<script lang="ts">
import BackButton from '$lib/components/BackButton.svelte';
</script>
<BackButton />
<a href="/persons" class="inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4">
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" .../>
Zurück zur Übersicht
</a>
```
The component calls `history.back()` so the user returns to wherever they came from. Label is always "Zurück" (no contextual suffix — destination is unknown). Touch target ≥ 44px and focus ring are built in. Do not use a static `<a href>` for back navigation.
Subtle action link (e.g. "new document/person"):
```svelte

View File

@@ -1,3 +0,0 @@
### Mark all blocks as reviewed
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
Authorization: Basic admin admin123

View File

@@ -164,19 +164,12 @@
<version>3.0.2</version>
</dependency>
<!-- PDF rendering for training data export and thumbnail generation -->
<!-- PDF rendering for training data export -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.4</version>
</dependency>
<!-- TIFF decoding plugin for ImageIO (thumbnail generation from scanned TIFFs) -->
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-tiff</artifactId>
<version>3.12.0</version>
</dependency>
</dependencies>

View File

@@ -12,9 +12,4 @@ public interface ActivityFeedRow {
UUID getDocumentId();
Instant getHappenedAt();
boolean isYouMentioned();
boolean isYouParticipated();
int getCount();
Instant getHappenedAtUntil();
/** Present only for COMMENT_ADDED and MENTION_CREATED — null otherwise. */
UUID getCommentId();
}

View File

@@ -1,7 +1,5 @@
package org.raddatz.familienarchiv.audit;
import java.util.Set;
public enum AuditKind {
/** Payload: none */
@@ -27,18 +25,4 @@ public enum AuditKind {
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
MENTION_CREATED,
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
USER_CREATED,
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
USER_DELETED,
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
GROUP_MEMBERSHIP_CHANGED;
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
);
}

View File

@@ -1,13 +1,10 @@
package org.raddatz.familienarchiv.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -26,92 +23,36 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
Optional<UUID> findMostRecentDocumentIdByActor(@Param("userId") UUID userId);
@Query(value = """
WITH events AS (
SELECT
a.kind,
a.actor_id,
a.document_id,
a.happened_at,
a.payload,
LAG(a.happened_at) OVER (
PARTITION BY a.actor_id, a.document_id, a.kind
ORDER BY a.happened_at
) AS prev_happened_at
FROM audit_log a
WHERE a.kind IN (:kinds)
AND a.document_id IS NOT NULL
),
sessions_marked AS (
SELECT
kind, actor_id, document_id, happened_at, payload,
SELECT * FROM (
SELECT DISTINCT ON (a.actor_id, a.document_id, a.kind, date_trunc('hour', a.happened_at))
a.kind AS kind,
a.actor_id AS actorId,
CASE
WHEN kind IN ('COMMENT_ADDED','MENTION_CREATED') THEN 1
WHEN prev_happened_at IS NULL THEN 1
WHEN EXTRACT(EPOCH FROM (happened_at - prev_happened_at)) > 7200 THEN 1
ELSE 0
END AS is_new_session
FROM events
),
sessions AS (
SELECT
kind, actor_id, document_id, happened_at, payload,
SUM(is_new_session) OVER (
PARTITION BY actor_id, document_id, kind
ORDER BY happened_at
ROWS UNBOUNDED PRECEDING
) AS session_id
FROM sessions_marked
),
aggregated AS (
SELECT
s.kind,
s.actor_id,
s.document_id,
s.session_id,
MIN(s.happened_at) AS happened_at,
CASE WHEN COUNT(*) > 1 THEN MAX(s.happened_at) ELSE NULL END AS happened_at_until,
COUNT(*)::int AS count,
BOOL_OR(s.kind = 'MENTION_CREATED'
AND s.payload->>'mentionedUserId' = :currentUserId) AS you_mentioned,
-- COMMENT_ADDED/MENTION_CREATED always have is_new_session=1, so each group has one row and MIN collapses to that row payload
MIN(s.payload::text)::jsonb AS payload
FROM sessions s
GROUP BY s.kind, s.actor_id, s.document_id, s.session_id
)
SELECT
ag.kind AS kind,
ag.actor_id AS actorId,
CASE
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
ELSE '?'
END AS actorInitials,
COALESCE(u.color, '') AS actorColor,
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
ag.document_id AS documentId,
ag.happened_at AS happened_at,
ag.you_mentioned AS youMentioned,
-- payload->>'commentId' matches notifications.reference_id per AuditKind.COMMENT_ADDED contract
EXISTS(
SELECT 1 FROM notifications n
WHERE n.type = 'REPLY'
AND n.recipient_id = CAST(:currentUserId AS uuid)
AND n.reference_id = (ag.payload->>'commentId')::uuid
) AS youParticipated,
ag.count AS count,
ag.happened_at_until AS happenedAtUntil,
(ag.payload->>'commentId')::uuid AS commentId
FROM aggregated ag
LEFT JOIN users u ON u.id = ag.actor_id
ORDER BY ag.happened_at DESC
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
ELSE '?'
END AS actorInitials,
COALESCE(u.color, '') AS actorColor,
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
a.document_id AS documentId,
a.happened_at AS happened_at,
(a.kind = 'MENTION_CREATED'
AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_id
WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED','COMMENT_ADDED','MENTION_CREATED')
AND a.document_id IS NOT NULL
ORDER BY a.actor_id, a.document_id, a.kind,
date_trunc('hour', a.happened_at), a.happened_at DESC
) deduped
ORDER BY happened_at DESC
LIMIT :limit
""", nativeQuery = true)
List<ActivityFeedRow> findRolledUpActivityFeed(
List<ActivityFeedRow> findDedupedActivityFeed(
@Param("currentUserId") String currentUserId,
@Param("limit") int limit,
@Param("kinds") Collection<String> kinds);
@Param("limit") int limit);
@Query(value = """
SELECT
@@ -165,40 +106,4 @@ public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
ORDER BY a.document_id, MIN(a.happened_at)
""", nativeQuery = true)
List<ContributorRow> findContributorsPerDocument(@Param("documentIds") List<UUID> documentIds);
@Query(value = """
SELECT
ranked.document_id AS documentId,
ranked.actorInitials AS actorInitials,
ranked.actorColor AS actorColor,
ranked.actorName AS actorName
FROM (
SELECT
a.document_id,
CASE
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
ELSE '?'
END AS actorInitials,
COALESCE(u.color, '') AS actorColor,
NULLIF(CONCAT_WS(' ', u.first_name, u.last_name), '') AS actorName,
ROW_NUMBER() OVER (
PARTITION BY a.document_id
ORDER BY MAX(a.happened_at) DESC
) AS rn
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_id
WHERE a.kind IN ('ANNOTATION_CREATED', 'TEXT_SAVED', 'BLOCK_REVIEWED')
AND a.document_id IN :documentIds
AND a.actor_id IS NOT NULL
GROUP BY a.document_id, a.actor_id, u.first_name, u.last_name, u.color
) ranked
WHERE ranked.rn <= 4
ORDER BY ranked.document_id, ranked.rn
""", nativeQuery = true)
List<ContributorRow> findRecentContributorsForDocuments(@Param("documentIds") List<UUID> documentIds);
Page<AuditLog> findByKindIn(Collection<AuditKind> kinds, Pageable pageable);
}

View File

@@ -1,17 +1,11 @@
package org.raddatz.familienarchiv.audit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.*;
import static org.raddatz.familienarchiv.audit.AuditKind.GROUP_MEMBERSHIP_CHANGED;
import static org.raddatz.familienarchiv.audit.AuditKind.USER_CREATED;
import static org.raddatz.familienarchiv.audit.AuditKind.USER_DELETED;
@Service
@RequiredArgsConstructor
public class AuditLogQueryService {
@@ -23,12 +17,7 @@ public class AuditLogQueryService {
}
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit) {
return findActivityFeed(currentUserId, limit, AuditKind.ROLLUP_ELIGIBLE);
}
public List<ActivityFeedRow> findActivityFeed(UUID currentUserId, int limit, Set<AuditKind> kinds) {
List<String> kindNames = kinds.stream().map(Enum::name).toList();
return queryRepository.findRolledUpActivityFeed(currentUserId.toString(), limit, kindNames);
return queryRepository.findDedupedActivityFeed(currentUserId.toString(), limit);
}
public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) {
@@ -49,20 +38,7 @@ public class AuditLogQueryService {
public Map<UUID, List<ActivityActorDTO>> findContributorsPerDocument(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
return toContributorMap(queryRepository.findContributorsPerDocument(documentIds));
}
public Map<UUID, List<ActivityActorDTO>> findRecentContributorsPerDocument(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
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) {
List<ContributorRow> rows = queryRepository.findContributorsPerDocument(documentIds);
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
for (ContributorRow row : rows) {
result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>())

View File

@@ -5,5 +5,4 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
boolean existsByKind(AuditKind kind);
}

View File

@@ -37,19 +37,4 @@ public class AsyncConfig {
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
return executor;
}
@Bean("thumbnailExecutor")
public Executor thumbnailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("Thumbnail-");
// CallerRunsPolicy applies back-pressure to quick-upload batches and admin backfill
// instead of dropping work (shared taskExecutor uses AbortPolicy). Safe because the
// task is dispatched via TransactionSynchronization.afterCommit, which runs on a
// post-commit callback thread without active transaction synchronization.
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}

View File

@@ -6,7 +6,6 @@ import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
import org.raddatz.familienarchiv.service.MassImportService;
import org.raddatz.familienarchiv.service.ThumbnailBackfillService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -24,7 +23,6 @@ public class AdminController {
private final MassImportService massImportService;
private final DocumentService documentService;
private final DocumentVersionService documentVersionService;
private final ThumbnailBackfillService thumbnailBackfillService;
@PostMapping("/trigger-import")
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
@@ -49,15 +47,4 @@ public class AdminController {
int count = documentService.backfillFileHashes();
return ResponseEntity.ok(new BackfillResult(count));
}
@PostMapping("/generate-thumbnails")
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
thumbnailBackfillService.runBackfillAsync();
return ResponseEntity.accepted().body(thumbnailBackfillService.getStatus());
}
@GetMapping("/thumbnail-status")
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> thumbnailStatus() {
return ResponseEntity.ok(thumbnailBackfillService.getStatus());
}
}

View File

@@ -24,6 +24,67 @@ public class CommentController {
private final CommentService commentService;
private final UserService userService;
// ─── General document comments ────────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/comments")
public List<DocumentComment> getDocumentComments(@PathVariable UUID documentId) {
return commentService.getCommentsForDocument(documentId);
}
@PostMapping("/api/documents/{documentId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment postDocumentComment(
@PathVariable UUID documentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author);
}
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment replyToDocumentComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Annotation comments ──────────────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
public List<DocumentComment> getAnnotationComments(@PathVariable UUID annotationId) {
return commentService.getCommentsForAnnotation(annotationId);
}
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment postAnnotationComment(
@PathVariable UUID documentId,
@PathVariable UUID annotationId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author);
}
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment replyToAnnotationComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Block (transcription) comments ────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")

View File

@@ -3,7 +3,6 @@ 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;
@@ -14,23 +13,10 @@ 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;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
@@ -75,7 +61,6 @@ import lombok.extern.slf4j.Slf4j;
@RequestMapping("/api/documents")
@RequiredArgsConstructor
@Slf4j
@Validated
public class DocumentController {
private final DocumentService documentService;
@@ -108,31 +93,6 @@ public class DocumentController {
}
}
// --- THUMBNAIL ---
@GetMapping("/{id}/thumbnail")
public ResponseEntity<InputStreamResource> getDocumentThumbnail(@PathVariable UUID id) {
Document doc = documentService.getDocumentById(id);
if (doc.getThumbnailKey() == null) {
throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND, "No thumbnail for document: " + id);
}
try {
FileService.S3FileDownload download = fileService.downloadFile(doc.getThumbnailKey());
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_JPEG)
// `private` (not `public`) prevents shared caches from serving one user's
// thumbnail to another (CWE-525). `immutable` is safe because the URL
// carries a ?v=<thumbnailGeneratedAt> cache-buster that changes whenever
// the underlying file is replaced.
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=31536000, immutable")
.body(download.resource());
} catch (FileService.StorageFileNotFoundException e) {
throw DomainException.notFound(ErrorCode.FILE_NOT_FOUND,
"Thumbnail missing in storage: " + doc.getThumbnailKey());
}
}
// --- METADATA ---
@GetMapping("/{id}")
public Document getDocument(@PathVariable UUID id) {
@@ -201,7 +161,6 @@ public class DocumentController {
@RequirePermission(Permission.WRITE_ALL)
public QuickUploadResult quickUpload(
@RequestPart(value = "files", required = false) List<MultipartFile> files,
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
Authentication authentication) {
List<Document> created = new ArrayList<>();
List<Document> updated = new ArrayList<>();
@@ -211,21 +170,14 @@ public class DocumentController {
return new QuickUploadResult(created, updated, errors);
}
documentService.validateBatch(files.size(), metadata);
UUID actorId = requireUserId(authentication);
long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();
for (int i = 0; i < files.size(); i++) {
MultipartFile file = files.get(i);
for (MultipartFile file : files) {
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
continue;
}
try {
DocumentService.StoreResult result = metadata != null
? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
: documentService.storeDocument(file, actorId);
DocumentService.StoreResult result = documentService.storeDocument(file, actorId);
if (result.isNew()) {
created.add(result.document());
} else {
@@ -237,123 +189,15 @@ public class DocumentController {
}
}
log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}",
actorId, files.size(), totalBytes, metadata != null,
created.size(), updated.size(), errors.size());
return new QuickUploadResult(created, updated, errors);
}
// --- BULK EDIT ---
private static final int BULK_EDIT_MAX_IDS = 500;
/** Hard cap for {@code GET /api/documents/ids}: prevents an unfiltered
* call from materialising the entire {@code documents} table into JSON.
* Generous enough for real-world "Alle X editieren" against the family
* archive's bounded scale (~1500 docs today, expected growth to ~5k). */
private static final int BULK_EDIT_FILTER_MAX_IDS = 5000;
@PatchMapping("/bulk")
@RequirePermission(Permission.WRITE_ALL)
public BulkEditResult patchBulk(
@RequestBody @Valid DocumentBulkEditDTO dto,
Authentication authentication) {
if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
}
if (dto.getDocumentIds().size() > BULK_EDIT_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Maximum " + BULK_EDIT_MAX_IDS + " documents per request, got: " + dto.getDocumentIds().size());
}
UUID actorId = requireUserId(authentication);
int updated = 0;
List<BulkEditError> errors = new ArrayList<>();
// Dedupe duplicate document IDs while preserving submission order. A
// double-click on "Alle X editieren" would otherwise hit each document
// twice and inflate the `updated` count returned to the user.
LinkedHashSet<UUID> uniqueIds = new LinkedHashSet<>(dto.getDocumentIds());
for (UUID id : uniqueIds) {
try {
documentService.applyBulkEditToDocument(id, dto, actorId);
updated++;
} catch (DomainException e) {
errors.add(new BulkEditError(id, sanitizeForLog(e.getMessage())));
} catch (Exception e) {
errors.add(new BulkEditError(id, "Internal error"));
log.warn("Bulk edit failed for document {}: {}", id, sanitizeForLog(e.getMessage()));
}
}
log.info("bulkEdit actor={} documentIds={} unique={} updated={} errors={}",
actorId, dto.getDocumentIds().size(), uniqueIds.size(), updated, errors.size());
return new BulkEditResult(updated, errors);
}
/** CRLF strip for any log line interpolating a free-form string (e.g.
* {@link Throwable#getMessage()}). Defends against CWE-117 log injection. */
private static String sanitizeForLog(String s) {
return s == null ? null : s.replaceAll("[\\r\\n]", "_");
}
@GetMapping("/ids")
@RequirePermission(Permission.WRITE_ALL)
public List<UUID> getDocumentIds(
@RequestParam(required = false) String q,
@RequestParam(required = false) LocalDate from,
@RequestParam(required = false) LocalDate to,
@RequestParam(required = false) UUID senderId,
@RequestParam(required = false) UUID receiverId,
@RequestParam(required = false, name = "tag") List<String> tags,
@RequestParam(required = false) String tagQ,
@RequestParam(required = false) DocumentStatus status,
@RequestParam(required = false) String tagOp,
Authentication authentication) {
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator);
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
}
UUID actorId = requireUserId(authentication);
log.info("documentIds actor={} matched={}", actorId, ids.size());
return ids;
}
@PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE)
@RequirePermission(Permission.READ_ALL)
public List<DocumentBatchSummary> batchMetadata(@RequestBody @Valid BatchMetadataRequest request, Authentication authentication) {
if (request == null || request.ids() == null || request.ids().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required");
}
if (request.ids().size() > BULK_EDIT_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Maximum " + BULK_EDIT_MAX_IDS + " ids per request, got: " + request.ids().size());
}
UUID actorId = requireUserId(authentication);
log.info("batchMetadata actor={} ids={}", actorId, request.ids().size());
return documentService.batchMetadata(request.ids());
}
@GetMapping("/incomplete-count")
@RequirePermission(Permission.WRITE_ALL)
public Map<String, Long> getIncompleteCount() {
return Map.of("count", documentService.getIncompleteCount());
}
@GetMapping("/incomplete")
@RequirePermission(Permission.WRITE_ALL)
public List<IncompleteDocumentDTO> getIncomplete(
@Parameter(description = "Maximum number of results (server caps at 200)")
@RequestParam(defaultValue = "50") int size) {
return documentService.findIncompleteDocuments(Math.min(size, 200));
}
@GetMapping("/incomplete/next")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
return documentService.findNextIncompleteDocument(excludeId)
.map(ResponseEntity::ok)
@@ -372,20 +216,14 @@ public class DocumentController {
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
// @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) {
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
}
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
// defaults to AND, which matches the frontend default and keeps old clients working.
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
Pageable pageable = PageRequest.of(page, size);
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator));
}
// --- TRAINING LABELS ---

View File

@@ -6,7 +6,6 @@ import jakarta.validation.ConstraintViolationException;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -48,12 +47,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorResponse> handleMessageNotReadable(HttpMessageNotReadableException ex) {
return ResponseEntity.badRequest()
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "Invalid request body"));
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode())

View File

@@ -34,13 +34,11 @@ public class PersonController {
private final DocumentService documentService;
@GetMapping
@RequirePermission(Permission.READ_ALL)
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
return ResponseEntity.ok(personService.findAll(q));
}
@GetMapping("/{id}")
@RequirePermission(Permission.READ_ALL)
public Person getPerson(@PathVariable UUID id) {
return personService.getById(id);
}
@@ -65,33 +63,27 @@ public class PersonController {
@PostMapping
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
validatePersonNames(dto);
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
return ResponseEntity.ok(personService.createPerson(dto));
}
@PutMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
validatePersonNames(dto);
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
return ResponseEntity.ok(personService.updatePerson(id, dto));
}
private void validatePersonNames(PersonUpdateDTO dto) {
if (dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nachname ist Pflichtfeld");
}
if (dto.getPersonType() == org.raddatz.familienarchiv.model.PersonType.PERSON
&& (dto.getFirstName() == null || dto.getFirstName().isBlank())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vorname ist Pflichtfeld");
}
}
@PostMapping("/{id}/merge")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)

View File

@@ -1,6 +1,5 @@
package org.raddatz.familienarchiv.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
@@ -46,7 +45,7 @@ public class TranscriptionBlockController {
@RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock createBlock(
@PathVariable UUID documentId,
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
@RequestBody CreateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.createBlock(documentId, dto, userId);
@@ -57,7 +56,7 @@ public class TranscriptionBlockController {
public TranscriptionBlock updateBlock(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@Valid @RequestBody UpdateTranscriptionBlockDTO dto,
@RequestBody UpdateTranscriptionBlockDTO dto,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
@@ -91,15 +90,6 @@ public class TranscriptionBlockController {
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")
@RequirePermission(Permission.READ_ALL)
public List<TranscriptionBlockVersion> getBlockHistory(

View File

@@ -78,31 +78,24 @@ public class UserController {
@PostMapping("/users")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> createUser(Authentication authentication,
@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request));
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.ok(userService.createUserOrUpdate(request));
}
@PutMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
@PathVariable UUID id,
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
@RequestBody AdminUpdateUserRequest dto) {
AppUser updated = userService.adminUpdateUser(actorId(authentication), id, dto);
AppUser updated = userService.adminUpdateUser(id, dto);
updated.setPassword(null);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<Void> deleteUser(Authentication authentication,
@PathVariable UUID id) {
userService.deleteUser(actorId(authentication), id);
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
userService.deleteUser(id);
return ResponseEntity.ok().build();
}
private UUID actorId(Authentication auth) {
return userService.findByEmail(auth.getName()).getId();
}
}

View File

@@ -14,20 +14,5 @@ public record ActivityFeedItemDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String documentTitle,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) OffsetDateTime happenedAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youParticipated,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count,
@Nullable OffsetDateTime happenedAtUntil,
@Nullable
@Schema(
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
description = "Deep-link target comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
)
UUID commentId,
@Nullable
@Schema(
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
description = "Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds."
)
UUID annotationId
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean youMentioned
) {}

View File

@@ -1,10 +1,6 @@
package org.raddatz.familienarchiv.dashboard;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.security.SecurityUtils;
@@ -13,7 +9,6 @@ import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@RestController
@@ -40,12 +35,8 @@ public class DashboardController {
@GetMapping("/activity")
public List<ActivityFeedItemDTO> getActivity(
Authentication authentication,
@RequestParam(defaultValue = "7") int limit,
@Parameter(description = "Filter by audit kinds; omit for all rollup-eligible kinds",
array = @ArraySchema(schema = @Schema(implementation = AuditKind.class)))
@RequestParam(required = false) Set<AuditKind> kinds) {
@RequestParam(defaultValue = "7") int limit) {
UUID userId = SecurityUtils.requireUserId(authentication, userService);
Set<AuditKind> effectiveKinds = (kinds == null || kinds.isEmpty()) ? AuditKind.ROLLUP_ELIGIBLE : kinds;
return dashboardService.getActivity(userId, Math.min(limit, 40), effectiveKinds);
return dashboardService.getActivity(userId, Math.min(limit, 20));
}
}

View File

@@ -4,14 +4,12 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.PulseStatsRow;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.service.CommentService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.TranscriptionService;
import org.raddatz.familienarchiv.service.UserService;
@@ -34,7 +32,6 @@ public class DashboardService {
private final DocumentService documentService;
private final TranscriptionService transcriptionService;
private final UserService userService;
private final CommentService commentService;
public DashboardResumeDTO getResume(UUID userId) {
Optional<UUID> docIdOpt = auditLogQueryService.findMostRecentDocumentForUser(userId);
@@ -82,7 +79,7 @@ public class DashboardService {
.toList();
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
totalBlocks, pct, doc.getThumbnailUrl(), collaborators);
totalBlocks, pct, null, collaborators);
}
public DashboardPulseDTO getPulse(UUID userId) {
@@ -111,8 +108,8 @@ public class DashboardService {
);
}
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit, Set<AuditKind> kinds) {
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit, kinds);
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit) {
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit);
List<UUID> docIds = rows.stream()
.map(ActivityFeedRow::getDocumentId)
@@ -128,37 +125,18 @@ public class DashboardService {
log.warn("Activity: failed to bulk-load document titles", e);
}
List<UUID> commentIds = rows.stream()
.map(ActivityFeedRow::getCommentId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<UUID, UUID> annotationByComment = commentIds.isEmpty()
? Map.of()
: commentService.findAnnotationIdsByIds(commentIds);
return rows.stream().map(row -> {
ActivityActorDTO actor = row.getActorId() != null
? new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName())
: null;
String docTitle = titleCache.getOrDefault(row.getDocumentId(), "");
OffsetDateTime happenedAtUntil = row.getHappenedAtUntil() != null
? row.getHappenedAtUntil().atOffset(ZoneOffset.UTC)
: null;
UUID commentId = row.getCommentId();
UUID annotationId = commentId != null ? annotationByComment.get(commentId) : null;
return new ActivityFeedItemDTO(
org.raddatz.familienarchiv.audit.AuditKind.valueOf(row.getKind()),
actor,
row.getDocumentId(),
docTitle,
row.getHappenedAt().atOffset(ZoneOffset.UTC),
row.isYouMentioned(),
row.isYouParticipated(),
row.getCount(),
happenedAtUntil,
commentId,
annotationId
row.isYouMentioned()
);
}).toList();
}

View File

@@ -1,9 +0,0 @@
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) {}

View File

@@ -1,9 +0,0 @@
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) {}

View File

@@ -1,9 +0,0 @@
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) {}

View File

@@ -1,21 +1,14 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.raddatz.familienarchiv.model.PersonMention;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CreateTranscriptionBlockDTO {
@Min(0)
private int pageNumber;
@@ -29,8 +22,4 @@ public class CreateTranscriptionBlockDTO {
private double height;
private String text;
private String label;
@Valid
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
}

View File

@@ -1,18 +0,0 @@
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;
}

View File

@@ -1,10 +0,0 @@
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) {}

View File

@@ -1,60 +0,0 @@
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;
}

View File

@@ -1,18 +0,0 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.model.Document;
import java.util.List;
public record DocumentSearchItem(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Document document,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
SearchMatchData matchData,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int completionPercentage,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<ActivityActorDTO> contributors
) {}

View File

@@ -1,38 +1,35 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.data.domain.Pageable;
import org.raddatz.familienarchiv.model.Document;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record DocumentSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<DocumentSearchItem> items,
List<Document> documents,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long totalElements,
long total,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int pageNumber,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int pageSize,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int totalPages
Map<UUID, SearchMatchData> matchData
) {
/**
* 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.
* Creates a fully-enriched result from documents and their match overlay data.
* Absent map entries (e.g. document deleted between FTS and enrichment) are safe —
* the frontend treats a missing entry as "no match data".
*/
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
int size = items.size();
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
public static DocumentSearchResult withMatchData(List<Document> documents, Map<UUID, SearchMatchData> matchData) {
return new DocumentSearchResult(documents, documents.size(), matchData);
}
/**
* 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).
* Creates a result without match data — used for filter-only searches (no text query).
* No pagination yet — the full matched set is always returned.
* When pagination is added, total must come from a DB COUNT query, not list.size().
*/
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);
public static DocumentSearchResult of(List<Document> documents) {
return withMatchData(documents, Map.of());
}
}

View File

@@ -13,8 +13,6 @@ public class DocumentUpdateDTO {
private LocalDate documentDate;
private String location;
private String documentLocation;
private String archiveBox;
private String archiveFolder;
private String transcription;
private String summary;
private UUID senderId;

View File

@@ -2,11 +2,9 @@ package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.UUID;
public record IncompleteDocumentDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime uploadedAt
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title
) {}

View File

@@ -17,7 +17,6 @@ public interface PersonSummaryDTO {
Integer getBirthYear();
Integer getDeathYear();
String getNotes();
boolean isFamilyMember();
long getDocumentCount();
default String getDisplayName() {

View File

@@ -1,14 +1,10 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.raddatz.familienarchiv.model.PersonType;
@Data
public class PersonUpdateDTO {
@NotNull
private PersonType personType;
@Size(max = 50)
private String title;
@Size(max = 100)

View File

@@ -1,24 +1,13 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.raddatz.familienarchiv.model.PersonMention;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UpdateTranscriptionBlockDTO {
private String text;
private String label;
@Valid
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
}

View File

@@ -13,8 +13,7 @@ public enum ErrorCode {
PERSON_NOT_FOUND,
/** A person name alias with the given ID does not exist. 404 */
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
// --- Documents ---
/** A document with the given ID does not exist. 404 */
DOCUMENT_NOT_FOUND,
@@ -39,10 +38,6 @@ public enum ErrorCode {
/** A mass import is already in progress; only one can run at a time. 409 */
IMPORT_ALREADY_RUNNING,
// --- Thumbnails ---
/** A thumbnail backfill is already in progress; only one can run at a time. 409 */
THUMBNAIL_BACKFILL_ALREADY_RUNNING,
// --- Invites ---
/** The invite code does not exist. 404 */
INVITE_NOT_FOUND,
@@ -95,14 +90,6 @@ public enum ErrorCode {
/** Internal inconsistency: expected training run row was not found after creation. 500 */
OCR_TRAINING_CONFLICT,
// --- Relationships (Stammbaum) ---
/** A relationship row with the given ID does not exist. 404 */
RELATIONSHIP_NOT_FOUND,
/** Adding this relationship would create a cycle (e.g. reverse PARENT_OF already exists). 409 */
CIRCULAR_RELATIONSHIP,
/** A relationship with the same (person, relatedPerson, type) already exists. 409 */
DUPLICATE_RELATIONSHIP,
// --- Tags ---
/** A tag with the given ID does not exist. 404 */
TAG_NOT_FOUND,
@@ -118,10 +105,6 @@ public enum ErrorCode {
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR,
/** Batch upload exceeds the maximum allowed file count per request. 400 */
BATCH_TOO_LARGE,
/** Bulk edit request exceeds the per-request document ID cap. 400 */
BULK_EDIT_TOO_MANY_IDS,
/** An unexpected server-side error occurred. 500 */
INTERNAL_ERROR,
}

View File

@@ -6,11 +6,8 @@ import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
@@ -46,20 +43,6 @@ public class Document {
@Column(name = "file_hash", length = 64)
private String fileHash;
// S3 key of the generated thumbnail (e.g. "thumbnails/{docId}.jpg"); null until generated
@Column(name = "thumbnail_key")
private String thumbnailKey;
@Column(name = "thumbnail_generated_at")
private LocalDateTime thumbnailGeneratedAt;
@Enumerated(EnumType.STRING)
@Column(name = "thumbnail_aspect", length = 16)
private ThumbnailAspect thumbnailAspect;
@Column(name = "page_count")
private Integer pageCount;
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
@Column(name = "original_filename", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@@ -134,19 +117,4 @@ public class Document {
@Enumerated(EnumType.STRING)
@Builder.Default
private Set<TrainingLabel> trainingLabels = new HashSet<>();
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
// this URL changes whenever the underlying file does. Dropping the query param
// would let browsers serve a stale thumbnail for a year after the file is
// replaced, and shared caches could leak one user's thumbnail to another
// (CWE-525).
@JsonProperty("thumbnailUrl")
public String getThumbnailUrl() {
if (thumbnailKey == null) return null;
String base = "/api/documents/" + id + "/thumbnail";
if (thumbnailGeneratedAt == null) return base;
return base + "?v=" + URLEncoder.encode(thumbnailGeneratedAt.toString(), StandardCharsets.UTF_8);
}
}

View File

@@ -47,11 +47,6 @@ public class Person {
private Integer birthYear;
private Integer deathYear;
@Column(name = "family_member", nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean familyMember = false;
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
// Uses entity relationship rather than cross-domain repository access, avoiding a
// separate DB roundtrip while respecting domain boundaries.

View File

@@ -1,31 +0,0 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Embeddable
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PersonMention {
@NotNull
@Column(name = "person_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID personId;
@NotNull
@Size(max = 200)
@Column(name = "display_name", nullable = false, length = 200)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
// Archival: the text the transcriber typed after @. Never updated on person rename.
private String displayName;
}

View File

@@ -1,6 +0,0 @@
package org.raddatz.familienarchiv.model;
public enum ThumbnailAspect {
PORTRAIT,
LANDSCAPE
}

View File

@@ -7,8 +7,6 @@ import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@@ -35,16 +33,6 @@ public class TranscriptionBlock {
@Column(columnDefinition = "TEXT")
private String text;
// EAGER: mention set is bounded by block text length (typically < 20 entries).
// Switching back to LAZY requires callers to be inside an open Hibernate session.
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "transcription_block_mentioned_persons",
joinColumns = @JoinColumn(name = "block_id"))
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private List<PersonMention> mentionedPersons = new ArrayList<>();
@Column(length = 200)
private String label;

View File

@@ -1,55 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.raddatz.familienarchiv.model.Person;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "person_relationships")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString(exclude = "notes")
public class PersonRelationship {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "person_id", nullable = false)
@JsonIgnore
private Person person;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "related_person_id", nullable = false)
@JsonIgnore
private Person relatedPerson;
@Enumerated(EnumType.STRING)
@Column(name = "relation_type", nullable = false, length = 30)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private RelationType relationType;
@Column(name = "from_year")
private Integer fromYear;
@Column(name = "to_year")
private Integer toYear;
@Column(length = 2000)
private String notes;
@CreationTimestamp
@Column(name = "created_at", updatable = false, nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private Instant createdAt;
}

View File

@@ -1,49 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@Repository
public interface PersonRelationshipRepository extends JpaRepository<PersonRelationship, UUID> {
/**
* Bulk fetch for the network endpoint — pulls only edges of the given types.
* The service filters by family_member afterwards.
*/
@Query("SELECT r FROM PersonRelationship r " +
"JOIN FETCH r.person " +
"JOIN FETCH r.relatedPerson " +
"WHERE r.relationType IN :types")
List<PersonRelationship> findAllByRelationTypeIn(@Param("types") Collection<RelationType> types);
/** Used for the circular-PARENT_OF check in {@code addRelationship}. */
boolean existsByPersonIdAndRelatedPersonIdAndRelationType(
UUID personId, UUID relatedPersonId, RelationType relationType);
/**
* All edges incident on {@code personId} (either side) restricted to the given types.
* Used by the inference service to load a person's local subgraph for BFS.
*/
@Query("SELECT r FROM PersonRelationship r " +
"WHERE (r.person.id = :personId OR r.relatedPerson.id = :personId) " +
"AND r.relationType IN :types")
List<PersonRelationship> findAllByPersonIdOrRelatedPersonIdAndRelationTypeIn(
@Param("personId") UUID personId,
@Param("types") Collection<RelationType> types);
/**
* All edges incident on {@code personId} (either side), all types.
* Used by the "direct relationships" listings (person edit, side panel).
*/
@Query("SELECT r FROM PersonRelationship r " +
"JOIN FETCH r.person " +
"JOIN FETCH r.relatedPerson " +
"WHERE r.person.id = :personId OR r.relatedPerson.id = :personId")
List<PersonRelationship> findAllByPersonIdOrRelatedPersonId(@Param("personId") UUID personId);
}

View File

@@ -1,24 +0,0 @@
package org.raddatz.familienarchiv.relationship;
/**
* Abstract direction tokens emitted by the BFS in {@link RelationshipInferenceService}.
* A path is a list of these tokens — e.g. niece-of-me is {@code [SIBLING, DOWN]}.
*
* <p>Reversing a path swaps {@link #UP} ↔ {@link #DOWN} and leaves the symmetric
* tokens ({@link #SPOUSE}, {@link #SIBLING}) untouched.
*/
public enum RelationToken {
UP,
DOWN,
SPOUSE,
SIBLING;
public RelationToken reverse() {
return switch (this) {
case UP -> DOWN;
case DOWN -> UP;
case SPOUSE -> SPOUSE;
case SIBLING -> SIBLING;
};
}
}

View File

@@ -1,20 +0,0 @@
package org.raddatz.familienarchiv.relationship;
/**
* Family-network relationship taxonomy.
*
* <p>Symmetric types ({@link #SPOUSE_OF}, {@link #SIBLING_OF}) are stored once;
* the inference service walks them in both directions. {@link #PARENT_OF} is
* directional: A PARENT_OF B means A is the parent.
*/
public enum RelationType {
PARENT_OF,
SPOUSE_OF,
SIBLING_OF,
FRIEND,
COLLEAGUE,
EMPLOYER,
DOCTOR,
NEIGHBOR,
OTHER
}

View File

@@ -1,84 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.FamilyMemberPatchDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
/**
* Stammbaum API. Endpoints split across two roots:
* <ul>
* <li>{@code /api/network} — the family graph</li>
* <li>{@code /api/persons/{id}/...} — per-person relationship operations
* (PersonController is intentionally left untouched)</li>
* </ul>
*/
@RestController
@RequiredArgsConstructor
public class RelationshipController {
private final RelationshipService relationshipService;
// READ endpoints carry no @RequirePermission: all authenticated users may read the family graph.
// Unauthenticated requests are rejected by Spring Security's anyRequest().authenticated() rule.
@GetMapping("/api/network")
public NetworkDTO getNetwork() {
return relationshipService.getFamilyNetwork();
}
@GetMapping("/api/persons/{id}/relationships")
public List<RelationshipDTO> getRelationships(@PathVariable UUID id) {
return relationshipService.getRelationships(id);
}
@GetMapping("/api/persons/{id}/inferred-relationships")
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(@PathVariable UUID id) {
return relationshipService.getInferredRelationships(id);
}
@GetMapping("/api/persons/{aId}/relationship-to/{bId}")
public InferredRelationshipDTO getRelationshipBetween(@PathVariable UUID aId, @PathVariable UUID bId) {
return relationshipService.getRelationshipBetween(aId, bId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "No relationship path between " + aId + " and " + bId));
}
@PostMapping("/api/persons/{id}/relationships")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<RelationshipDTO> addRelationship(
@PathVariable UUID id,
@Valid @RequestBody CreateRelationshipRequest dto) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(relationshipService.addRelationship(id, dto));
}
@DeleteMapping("/api/persons/{id}/relationships/{relId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)
public void deleteRelationship(@PathVariable UUID id, @PathVariable UUID relId) {
relationshipService.deleteRelationship(id, relId);
}
@PatchMapping("/api/persons/{id}/family-member")
@RequirePermission(Permission.WRITE_ALL)
public Person patchFamilyMember(@PathVariable UUID id, @RequestBody FamilyMemberPatchDTO dto) {
return relationshipService.setFamilyMember(id, dto.familyMember());
}
}

View File

@@ -1,211 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* Derives indirect family relationships by BFS over the family-graph subset
* (PARENT_OF, SPOUSE_OF, SIBLING_OF). Time-ignorant: from_year / to_year are
* not consulted. Siblings are also derived from shared parents — no SIBLING_OF
* row is required.
*/
@Service
@RequiredArgsConstructor
public class RelationshipInferenceService {
// 8 hops covers great-grandparents ↔ great-great-grandchildren and second cousins —
// the practical horizon for a 18991950 family archive. Paths longer than this are
// classified as LABEL_DISTANT and rarely carry meaningful relationship labels.
static final int MAX_DEPTH = 8;
/** "distant" is the catch-all label for paths that do not match the LABEL_MAP. */
static final String LABEL_DISTANT = "distant";
private static final Map<List<RelationToken>, String> LABEL_MAP = buildLabelMap();
private final PersonRelationshipRepository relationshipRepository;
private final PersonService personService;
private static Map<List<RelationToken>, String> buildLabelMap() {
Map<List<RelationToken>, String> m = new HashMap<>();
m.put(List.of(RelationToken.UP), "parent");
m.put(List.of(RelationToken.DOWN), "child");
m.put(List.of(RelationToken.SPOUSE), "spouse");
m.put(List.of(RelationToken.SIBLING), "sibling");
m.put(List.of(RelationToken.UP, RelationToken.UP), "grandparent");
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN), "grandchild");
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.UP), "great_grandparent");
m.put(List.of(RelationToken.DOWN, RelationToken.DOWN, RelationToken.DOWN), "great_grandchild");
m.put(List.of(RelationToken.UP, RelationToken.SIBLING), "uncle_aunt");
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN), "niece_nephew");
m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.SIBLING), "great_uncle_aunt");
m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN, RelationToken.DOWN), "great_niece_nephew");
m.put(List.of(RelationToken.SPOUSE, RelationToken.UP), "inlaw_parent");
m.put(List.of(RelationToken.DOWN, RelationToken.SPOUSE), "inlaw_child");
m.put(List.of(RelationToken.SPOUSE, RelationToken.SIBLING), "sibling_inlaw");
m.put(List.of(RelationToken.SIBLING, RelationToken.SPOUSE), "sibling_inlaw");
m.put(List.of(RelationToken.UP, RelationToken.SIBLING, RelationToken.DOWN), "cousin_1");
return Collections.unmodifiableMap(m);
}
/**
* Shortest token path from {@code from} to {@code to}, or empty if unreachable
* within {@link #MAX_DEPTH} hops. Package-private to permit direct path
* assertions in unit tests.
*/
Optional<List<RelationToken>> findShortestPath(UUID from, UUID to) {
if (from.equals(to)) return Optional.empty();
Map<UUID, List<Edge>> adj = buildAdjacency();
return bfs(adj, from, to);
}
/** Two-sided label between A and B. {@code labelFromA} reads "B is my <labelFromA>". */
public Optional<InferredRelationshipDTO> infer(UUID a, UUID b) {
Optional<List<RelationToken>> aToB = findShortestPath(a, b);
if (aToB.isEmpty()) return Optional.empty();
List<RelationToken> path = aToB.get();
return Optional.of(new InferredRelationshipDTO(
labelFor(path),
labelFor(reversePath(path)),
path.size()));
}
/** All persons reachable from {@code personId} within MAX_DEPTH, with their labels. */
public List<InferredRelationshipWithPersonDTO> findAllFor(UUID personId) {
Map<UUID, List<Edge>> adj = buildAdjacency();
Map<UUID, List<RelationToken>> shortestPaths = bfsAll(adj, personId);
shortestPaths.remove(personId);
if (shortestPaths.isEmpty()) return List.of();
List<UUID> ids = new ArrayList<>(shortestPaths.keySet());
Map<UUID, Person> byId = new HashMap<>();
for (Person p : personService.getAllById(ids)) {
byId.put(p.getId(), p);
}
List<InferredRelationshipWithPersonDTO> out = new ArrayList<>();
for (UUID id : ids) {
Person p = byId.get(id);
if (p == null) continue;
List<RelationToken> path = shortestPaths.get(id);
PersonNodeDTO node = new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember());
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
}
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
.thenComparing(d -> d.person().displayName()));
return out;
}
static String labelFor(List<RelationToken> path) {
String specific = LABEL_MAP.get(path);
return specific != null ? specific : LABEL_DISTANT;
}
private static List<RelationToken> reversePath(List<RelationToken> path) {
List<RelationToken> reversed = new ArrayList<>(path.size());
for (int i = path.size() - 1; i >= 0; i--) {
reversed.add(path.get(i).reverse());
}
return List.copyOf(reversed);
}
private Map<UUID, List<Edge>> buildAdjacency() {
List<PersonRelationship> edges = relationshipRepository.findAllByRelationTypeIn(
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
Map<UUID, List<Edge>> adj = new HashMap<>();
Map<UUID, List<UUID>> parentToChildren = new HashMap<>();
for (PersonRelationship e : edges) {
UUID a = e.getPerson().getId();
UUID b = e.getRelatedPerson().getId();
switch (e.getRelationType()) {
case PARENT_OF -> {
addEdge(adj, a, b, RelationToken.DOWN);
addEdge(adj, b, a, RelationToken.UP);
parentToChildren.computeIfAbsent(a, k -> new ArrayList<>()).add(b);
}
case SPOUSE_OF -> {
addEdge(adj, a, b, RelationToken.SPOUSE);
addEdge(adj, b, a, RelationToken.SPOUSE);
}
case SIBLING_OF -> {
addEdge(adj, a, b, RelationToken.SIBLING);
addEdge(adj, b, a, RelationToken.SIBLING);
}
default -> { /* family graph excludes other types */ }
}
}
for (List<UUID> children : parentToChildren.values()) {
for (int i = 0; i < children.size(); i++) {
for (int j = i + 1; j < children.size(); j++) {
UUID c1 = children.get(i);
UUID c2 = children.get(j);
addEdge(adj, c1, c2, RelationToken.SIBLING);
addEdge(adj, c2, c1, RelationToken.SIBLING);
}
}
}
return adj;
}
private static void addEdge(Map<UUID, List<Edge>> adj, UUID from, UUID to, RelationToken token) {
adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Edge(to, token));
}
private static Optional<List<RelationToken>> bfs(Map<UUID, List<Edge>> adj, UUID from, UUID to) {
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
shortest.put(from, List.of());
Deque<UUID> queue = new ArrayDeque<>();
queue.add(from);
while (!queue.isEmpty()) {
UUID curr = queue.poll();
List<RelationToken> currPath = shortest.get(curr);
if (currPath.size() >= MAX_DEPTH) continue;
for (Edge e : adj.getOrDefault(curr, List.of())) {
if (shortest.containsKey(e.target())) continue;
List<RelationToken> nextPath = append(currPath, e.token());
shortest.put(e.target(), nextPath);
if (e.target().equals(to)) return Optional.of(nextPath);
queue.add(e.target());
}
}
return Optional.empty();
}
private static Map<UUID, List<RelationToken>> bfsAll(Map<UUID, List<Edge>> adj, UUID from) {
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
shortest.put(from, List.of());
Deque<UUID> queue = new ArrayDeque<>();
queue.add(from);
while (!queue.isEmpty()) {
UUID curr = queue.poll();
List<RelationToken> currPath = shortest.get(curr);
if (currPath.size() >= MAX_DEPTH) continue;
for (Edge e : adj.getOrDefault(curr, List.of())) {
if (shortest.containsKey(e.target())) continue;
List<RelationToken> nextPath = append(currPath, e.token());
shortest.put(e.target(), nextPath);
queue.add(e.target());
}
}
return shortest;
}
private static List<RelationToken> append(List<RelationToken> prefix, RelationToken next) {
List<RelationToken> out = new ArrayList<>(prefix.size() + 1);
out.addAll(prefix);
out.add(next);
return List.copyOf(out);
}
private record Edge(UUID target, RelationToken token) {}
}

View File

@@ -1,168 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
/**
* Owns the {@code person_relationships} table and the family_member flag.
* Always orchestrates {@link PersonService} for cross-domain access — never
* touches {@link org.raddatz.familienarchiv.repository.PersonRepository}.
*/
@Service
@RequiredArgsConstructor
public class RelationshipService {
private final PersonRelationshipRepository relationshipRepository;
private final PersonService personService;
private final RelationshipInferenceService inferenceService;
public List<RelationshipDTO> getRelationships(UUID personId) {
personService.getById(personId);
List<PersonRelationship> rels = relationshipRepository.findAllByPersonIdOrRelatedPersonId(personId);
return rels.stream().map(RelationshipService::toDTO).toList();
}
public List<InferredRelationshipWithPersonDTO> getInferredRelationships(UUID personId) {
personService.getById(personId);
return inferenceService.findAllFor(personId);
}
public Optional<InferredRelationshipDTO> getRelationshipBetween(UUID a, UUID b) {
personService.getById(a);
personService.getById(b);
return inferenceService.infer(a, b);
}
public NetworkDTO getFamilyNetwork() {
// Two queries: 1 for nodes (family members), 1 for edges (family-graph types).
List<Person> familyMembers = personService.findAllFamilyMembers();
Set<UUID> familyIds = new HashSet<>(familyMembers.size());
List<PersonNodeDTO> nodes = new ArrayList<>(familyMembers.size());
for (Person p : familyMembers) {
familyIds.add(p.getId());
nodes.add(new PersonNodeDTO(
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), true));
}
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
List<RelationshipDTO> edges = new ArrayList<>();
for (PersonRelationship r : familyEdges) {
UUID p = r.getPerson().getId();
UUID rp = r.getRelatedPerson().getId();
if (familyIds.contains(p) && familyIds.contains(rp)) {
edges.add(toDTO(r));
}
}
return new NetworkDTO(nodes, edges);
}
@Transactional
public RelationshipDTO addRelationship(UUID personId, CreateRelationshipRequest dto) {
if (personId.equals(dto.relatedPersonId())) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Cannot relate a person to themselves");
}
Person person = personService.getById(personId);
Person relatedPerson = personService.getById(dto.relatedPersonId());
validateYears(dto.fromYear(), dto.toYear());
if (dto.relationType() == RelationType.PARENT_OF
&& relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
relatedPerson.getId(), personId, RelationType.PARENT_OF)) {
throw DomainException.conflict(
ErrorCode.CIRCULAR_RELATIONSHIP,
"Reverse PARENT_OF already exists between " + personId + " and " + relatedPerson.getId());
}
PersonRelationship rel = PersonRelationship.builder()
.person(person)
.relatedPerson(relatedPerson)
.relationType(dto.relationType())
.fromYear(dto.fromYear())
.toYear(dto.toYear())
.notes(blankToNull(dto.notes()))
.build();
try {
// saveAndFlush so the unique_rel constraint violates synchronously and is
// caught here, not at commit time outside the @Transactional boundary.
return toDTO(relationshipRepository.saveAndFlush(rel));
} catch (DataIntegrityViolationException e) {
throw DomainException.conflict(
ErrorCode.DUPLICATE_RELATIONSHIP,
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
}
}
@Transactional
public void deleteRelationship(UUID personId, UUID relId) {
PersonRelationship rel = relationshipRepository.findById(relId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.RELATIONSHIP_NOT_FOUND, "Relationship not found: " + relId));
UUID storageSubject = rel.getPerson().getId();
UUID storageObject = rel.getRelatedPerson().getId();
if (!personId.equals(storageSubject) && !personId.equals(storageObject)) {
throw DomainException.forbidden(
"Relationship " + relId + " does not belong to person " + personId);
}
relationshipRepository.delete(rel);
}
@Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) {
return personService.setFamilyMember(personId, familyMember);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
private static void validateYears(Integer fromYear, Integer toYear) {
if (fromYear != null && toYear != null && toYear < fromYear) {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "toYear must not be before fromYear");
}
}
private static RelationshipDTO toDTO(PersonRelationship r) {
Person p = r.getPerson();
Person rp = r.getRelatedPerson();
return new RelationshipDTO(
r.getId(),
p.getId(),
rp.getId(),
p.getDisplayName(),
p.getBirthYear(),
p.getDeathYear(),
rp.getDisplayName(),
rp.getBirthYear(),
rp.getDeathYear(),
r.getRelationType(),
r.getFromYear(),
r.getToYear(),
r.getNotes());
}
}

View File

@@ -1,15 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.raddatz.familienarchiv.relationship.RelationType;
import java.util.UUID;
public record CreateRelationshipRequest(
@NotNull UUID relatedPersonId,
@NotNull RelationType relationType,
Integer fromYear,
Integer toYear,
@Size(max = 2000) String notes
) {}

View File

@@ -1,4 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
/** Body for {@code PATCH /api/persons/{id}/family-member}. */
public record FamilyMemberPatchDTO(boolean familyMember) {}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Pairwise inferred relationship for the document badge.
* {@code labelFromA} reads "Person B, from A's point of view" and vice-versa
* (e.g. A=parent, B=child → labelFromA = "Sohn", labelFromB = "Vater").
*/
public record InferredRelationshipDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromA,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String labelFromB,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
) {}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* Used by {@code GET /api/persons/{id}/inferred-relationships}: each entry
* is a derived relationship to another family member, labelled from the
* requesting person's perspective.
*/
public record InferredRelationshipWithPersonDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) PersonNodeDTO person,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String label,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int hops
) {}

View File

@@ -1,11 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
/** Payload for {@code GET /api/network}. Nodes are family members; edges are family-graph relationships. */
public record NetworkDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<PersonNodeDTO> nodes,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<RelationshipDTO> edges
) {}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
/** Lightweight node for the Stammbaum tree and inferred-relationship payloads. */
public record PersonNodeDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
Integer birthYear,
Integer deathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
) {}

View File

@@ -1,32 +0,0 @@
package org.raddatz.familienarchiv.relationship.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.relationship.RelationType;
import java.util.UUID;
/**
* Wire shape for one stored relationship row. Both sides include name + years
* so the frontend can render the row from either perspective (e.g. on the
* subject's page the row reads "Elternteil von [related]"; on the object's
* page it reads "Kind von [person]").
*
* <p>Storage truth: {@code personId} is the {@code person_id} column,
* {@code relatedPersonId} is the {@code related_person_id} column. The
* frontend determines orientation by comparing against the viewpoint.
*/
public record RelationshipDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID personId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID relatedPersonId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String personDisplayName,
Integer personBirthYear,
Integer personDeathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String relatedPersonDisplayName,
Integer relatedPersonBirthYear,
Integer relatedPersonDeathYear,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) RelationType relationType,
Integer fromYear,
Integer toYear,
String notes
) {}

View File

@@ -8,6 +8,10 @@ import java.util.UUID;
public interface CommentRepository extends JpaRepository<DocumentComment, UUID> {
List<DocumentComment> findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId);
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
List<DocumentComment> findByParentId(UUID parentId);
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);

View File

@@ -1,8 +0,0 @@
package org.raddatz.familienarchiv.repository;
import java.util.UUID;
public interface CompletionStatsRow {
UUID getDocumentId();
int getCompletionPercentage();
}

View File

@@ -46,8 +46,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
List<Document> findByFilePathIsNotNullAndThumbnailKeyIsNull();
@Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids")
List<Object[]> findIdAndTitleByIdIn(@Param("ids") Collection<UUID> ids);
@@ -87,7 +85,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
SELECT d.id FROM documents d
CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('simple', regexp_replace(
THEN to_tsquery('german', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',
@@ -149,7 +147,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
FROM documents d
CROSS JOIN LATERAL (
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
THEN to_tsquery('simple', regexp_replace(
THEN to_tsquery('german', regexp_replace(
websearch_to_tsquery('german', :query)::text,
'''([^'']+)''',
'''\\1'':*',

View File

@@ -26,9 +26,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Hilfsmethode: Alle sortiert laden (für den leeren Status)
List<Person> findAllByOrderByLastNameAscFirstNameAsc();
// Stammbaum-Knoten: alle Personen mit family_member = true.
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
@@ -41,7 +38,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
@@ -54,7 +50,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
@@ -63,7 +58,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)

View File

@@ -5,39 +5,18 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface TranscriptionBlockRepository extends JpaRepository<TranscriptionBlock, UUID> {
@Query(value = """
SELECT
b.document_id AS documentId,
ROUND(COUNT(*) FILTER (WHERE b.reviewed = true) * 100.0 / COUNT(*))::int AS completionPercentage
FROM transcription_blocks b
WHERE b.document_id IN :documentIds
GROUP BY b.document_id
""", nativeQuery = true)
List<CompletionStatsRow> findCompletionStatsForDocuments(
@Param("documentIds") Collection<UUID> documentIds);
List<TranscriptionBlock> findByDocumentIdOrderBySortOrderAsc(UUID documentId);
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);
Optional<TranscriptionBlock> findByAnnotationId(UUID annotationId);
@Query("""
SELECT DISTINCT b FROM TranscriptionBlock b
JOIN FETCH b.mentionedPersons
WHERE b.id IN (
SELECT bb.id FROM TranscriptionBlock bb JOIN bb.mentionedPersons m WHERE m.personId = :personId
)
""")
List<TranscriptionBlock> findByPersonIdWithMentionsFetched(@Param("personId") UUID personId);
void deleteByAnnotationId(UUID annotationId);
int countByDocumentId(UUID documentId);
@@ -60,25 +39,21 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
""")
List<TranscriptionBlock> findSegmentationBlocks();
// Uses 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels — aligned with findEligibleKurrentBlocks()
// which already used this form (changed from d.scriptType = 'KURRENT' in the original queries).
@Query("""
SELECT COUNT(b) FROM TranscriptionBlock b
JOIN Document d ON d.id = b.documentId
WHERE b.source = 'MANUAL'
AND d.sender.id = :personId
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
AND d.scriptType = 'HANDWRITING_KURRENT'
""")
long countManualKurrentBlocksByPerson(@Param("personId") UUID personId);
// Uses 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels — aligned with findEligibleKurrentBlocks()
// which already used this form (changed from d.scriptType = 'KURRENT' in the original queries).
@Query("""
SELECT b FROM TranscriptionBlock b
JOIN Document d ON d.id = b.documentId
WHERE b.source = 'MANUAL'
AND d.sender.id = :personId
AND 'KURRENT_RECOGNITION' MEMBER OF d.trainingLabels
AND d.scriptType = 'HANDWRITING_KURRENT'
""")
List<TranscriptionBlock> findManualKurrentBlocksByPerson(@Param("personId") UUID personId);
}

View File

@@ -8,13 +8,10 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.CommentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -29,15 +26,16 @@ public class CommentService {
private final UserService userService;
private final NotificationService notificationService;
private final AuditService auditService;
private final TranscriptionService transcriptionService;
public Map<UUID, UUID> findAnnotationIdsByIds(Collection<UUID> commentIds) {
if (commentIds == null || commentIds.isEmpty()) return Map.of();
Map<UUID, UUID> result = new HashMap<>();
for (DocumentComment c : commentRepository.findAllById(commentIds)) {
if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId());
}
return result;
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
List<DocumentComment> roots =
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
return withRepliesAndMentions(roots);
}
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
return withRepliesAndMentions(roots);
}
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
@@ -48,11 +46,27 @@ public class CommentService {
@Transactional
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
List<UUID> mentionedUserIds, AppUser author) {
TranscriptionBlock block = transcriptionService.getBlock(documentId, blockId);
DocumentComment comment = DocumentComment.builder()
.documentId(documentId)
.blockId(blockId)
.annotationId(block.getAnnotationId())
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
saveMentions(comment, mentionedUserIds);
DocumentComment saved = commentRepository.save(comment);
withMentionDTOs(saved);
notificationService.notifyMentions(mentionedUserIds, saved);
logCommentPosted(author, documentId, saved, mentionedUserIds);
return saved;
}
@Transactional
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
List<UUID> mentionedUserIds, AppUser author) {
DocumentComment comment = DocumentComment.builder()
.documentId(documentId)
.annotationId(annotationId)
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))

View File

@@ -3,14 +3,8 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
@@ -25,9 +19,7 @@ import org.raddatz.familienarchiv.model.TrainingLabel;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.raddatz.familienarchiv.exception.DomainException;
@@ -67,9 +59,6 @@ public class DocumentService {
private final DocumentVersionService documentVersionService;
private final AnnotationService annotationService;
private final AuditService auditService;
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final AuditLogQueryService auditLogQueryService;
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
public record StoreResult(Document document, boolean isNew) {}
@@ -131,56 +120,9 @@ public class DocumentService {
if (wasPlaceholder) {
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
}
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
return new StoreResult(saved, isNew);
}
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
if (fileCount > 50) {
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
}
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
}
}
@Transactional
public StoreResult storeDocumentWithBatchMetadata(
MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {
StoreResult base = storeDocument(file, actorId);
Document doc = applyBatchMetadata(base.document(), metadata, fileIndex);
return new StoreResult(documentRepository.save(doc), base.isNew());
}
private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) {
if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) {
doc.setTitle(metadata.getTitles().get(fileIndex));
}
if (metadata.getSenderId() != null) {
doc.setSender(personService.getById(metadata.getSenderId()));
}
if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) {
doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds())));
}
if (metadata.getDocumentDate() != null) {
doc.setDocumentDate(metadata.getDocumentDate());
}
if (metadata.getLocation() != null) {
doc.setLocation(metadata.getLocation());
}
if (metadata.getMetadataComplete() != null) {
doc.setMetadataComplete(metadata.getMetadataComplete());
}
if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) {
UUID docId = doc.getId();
updateDocumentTags(docId, metadata.getTagNames());
doc = documentRepository.findById(docId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId));
}
return doc;
}
@Transactional
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
String filename = (file != null && !file.isEmpty())
@@ -240,8 +182,7 @@ public class DocumentService {
}
// Datei
boolean fileUploaded = file != null && !file.isEmpty();
if (fileUploaded) {
if (file != null && !file.isEmpty()) {
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
@@ -251,9 +192,6 @@ public class DocumentService {
Document finalDoc = documentRepository.save(doc);
documentVersionService.recordVersion(finalDoc);
if (fileUploaded) {
thumbnailAsyncRunner.dispatchAfterCommit(finalDoc.getId());
}
return finalDoc;
}
@@ -271,8 +209,6 @@ public class DocumentService {
doc.setTranscription(dto.getTranscription());
doc.setSummary(dto.getSummary());
doc.setDocumentLocation(dto.getDocumentLocation());
doc.setArchiveBox(dto.getArchiveBox());
doc.setArchiveFolder(dto.getArchiveFolder());
List<String> tags = new ArrayList<>();
if (dto.getTags() != null && !dto.getTags().isBlank()) {
@@ -308,8 +244,7 @@ public class DocumentService {
}
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
boolean fileReplaced = newFile != null && !newFile.isEmpty();
if (fileReplaced) {
if (newFile != null && !newFile.isEmpty()) {
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
@@ -328,153 +263,26 @@ public class DocumentService {
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), null);
}
if (fileReplaced) {
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
}
return saved;
}
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
Document doc = documentRepository.findById(docId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
doc.setTags(resolveTags(tagNames));
return documentRepository.save(doc);
}
/**
* 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<>();
Set<Tag> newTags = new HashSet<>();
for (String name : tagNames) {
// Clean the string
String cleanName = name.trim();
if (cleanName.isEmpty()) continue;
resolved.add(tagService.findOrCreate(cleanName));
}
return resolved;
}
if (cleanName.isEmpty())
continue;
/**
* 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();
newTags.add(tagService.findOrCreate(cleanName));
}
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
}
/**
* Single source of truth for the search Specification chain. Shared by
* {@link #searchDocuments} (paged + sorted) and {@link #findIdsForFilter}
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
* full-text query returned no rows.
*/
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
LocalDate from, LocalDate to,
UUID sender, UUID receiver,
List<String> tags, String tagQ,
DocumentStatus status, TagOperator tagOperator) {
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
return Specification.where(textSpec)
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ))
.and(hasStatus(status));
}
/**
* Returns lightweight summaries (id, title, server PDF URL) for the given
* document IDs. Unknown IDs are silently dropped — the consumer is the
* bulk-edit page's left strip, where missing previews would already be
* obvious; surfacing them as errors here adds no value.
*/
@Transactional(readOnly = true)
public List<DocumentBatchSummary> batchMetadata(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return List.of();
return documentRepository.findAllById(ids).stream()
.map(d -> new DocumentBatchSummary(
d.getId(),
d.getTitle() != null ? d.getTitle() : d.getOriginalFilename(),
"/api/documents/" + d.getId() + "/file"))
.toList();
}
/**
* Applies a bulk-edit DTO to a single document atomically.
* Tags and receivers are additive (merged into existing sets); sender and the
* three location fields are replace-on-non-blank (null/blank means "no change").
* Wrapped in its own transaction so a failure on one document never partially
* mutates another in the controller's batch loop.
*
* Each successful update emits a {@link AuditKind#METADATA_UPDATED} audit
* event tagged {@code source=BULK_EDIT} and writes a row to
* {@code document_versions} so the family archive's "who changed what"
* trail stays complete across both single- and bulk-doc edit paths.
*
* NOTE on N+1: tag and person resolution happens per-document. With 500
* documents × 10 tags this fans out to ~5000 tag-resolve queries per
* request. Acceptable today because the family archive is bounded at
* ~1500 documents total. Tracked as a perf follow-up.
*/
@Transactional
public Document applyBulkEditToDocument(UUID id, DocumentBulkEditDTO dto, UUID actorId) {
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
if (dto.getTagNames() != null && !dto.getTagNames().isEmpty()) {
Set<Tag> merged = new HashSet<>(doc.getTags());
merged.addAll(resolveTags(dto.getTagNames()));
doc.setTags(merged);
}
if (dto.getSenderId() != null) {
doc.setSender(personService.getById(dto.getSenderId()));
}
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
Set<Person> merged = new HashSet<>(doc.getReceivers());
merged.addAll(personService.getAllById(dto.getReceiverIds()));
doc.setReceivers(merged);
}
if (StringUtils.hasText(dto.getDocumentLocation())) {
doc.setDocumentLocation(dto.getDocumentLocation());
}
if (StringUtils.hasText(dto.getArchiveBox())) {
doc.setArchiveBox(dto.getArchiveBox());
}
if (StringUtils.hasText(dto.getArchiveFolder())) {
doc.setArchiveFolder(dto.getArchiveFolder());
}
Document saved = documentRepository.save(doc);
documentVersionService.recordVersion(saved);
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(),
Map.of("source", "BULK_EDIT"));
return saved;
doc.setTags(newTags);
return documentRepository.save(doc);
}
/**
@@ -516,7 +324,6 @@ public class DocumentService {
}
Document saved = documentRepository.save(doc);
documentVersionService.recordVersion(saved);
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
if (wasPlaceholder) {
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
}
@@ -531,30 +338,38 @@ public class DocumentService {
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
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) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
if (rankedIds.isEmpty()) return DocumentSearchResult.withMatchData(List.of(), Map.of());
}
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
// rank list. Cost scales linearly with match count — acceptable while documents
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
Specification<Document> spec = Specification.where(textSpec)
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ))
.and(hasStatus(status));
// SENDER and RECEIVER are sorted in-memory because JPA's Sort.by("sender.lastName")
// generates an INNER JOIN that silently drops documents with null sender/receivers.
if (sort == DocumentSort.RECEIVER) {
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
List<Document> results = documentRepository.findAll(spec);
List<Document> sorted = sortByFirstReceiver(results, dir);
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
}
if (sort == DocumentSort.SENDER) {
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
List<Document> results = documentRepository.findAll(spec);
List<Document> sorted = sortBySender(results, dir);
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
}
// RELEVANCE: default when text present and no explicit sort given
@@ -567,43 +382,12 @@ public class DocumentService {
.sorted(Comparator.comparingInt(
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
.toList();
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
}
// Fast path — push sort + paging into the DB and enrich only the returned slice.
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort, dir));
Page<Document> page = documentRepository.findAll(spec, pageRequest);
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
}
private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
int from = Math.min((int) pageable.getOffset(), sorted.size());
int to = Math.min(from + pageable.getPageSize(), sorted.size());
return sorted.subList(from, to);
}
private DocumentSearchResult buildResultPaged(List<Document> slice, String text, Pageable pageable, long totalElements) {
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
}
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
List<Document> colorResolved = resolveDocumentTagColors(documents);
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
List<UUID> docIds = colorResolved.stream().map(Document::getId).toList();
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
return colorResolved.stream().map(doc -> new DocumentSearchItem(
doc,
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
completionByDoc.getOrDefault(doc.getId(), 0),
contributorsByDoc.getOrDefault(doc.getId(), List.of())
)).toList();
}
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
return transcriptionBlockQueryService.getCompletionStats(docIds);
Sort springSort = resolveSort(sort, dir);
List<Document> results = documentRepository.findAll(spec, springSort);
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text));
}
private Sort resolveSort(DocumentSort sort, String dir) {
@@ -733,7 +517,7 @@ public class DocumentService {
PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
return documentRepository.findByMetadataCompleteFalse(pageable)
.stream()
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle(), doc.getCreatedAt()))
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle()))
.toList();
}

View File

@@ -112,27 +112,6 @@ public class FileService {
}
}
/**
* Opens a streaming download from S3/MinIO. The caller is responsible for
* closing the returned stream — typically via try-with-resources. Preferred
* over {@link #downloadFileBytes(String)} for large files (multi-MB PDFs
* during thumbnail generation) because it avoids loading the entire file
* into heap memory.
*/
public InputStream downloadFileStream(String s3Key) throws IOException {
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
return s3Client.getObject(getObjectRequest);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new IOException("Failed to open stream from storage: " + e.getMessage(), e);
}
}
/**
* Generates a presigned URL for downloading an object from S3/MinIO.
* Valid for 1 hour — covers multi-page documents on CPU-only OCR hardware

View File

@@ -59,7 +59,6 @@ public class MassImportService {
private final PersonService personService;
private final TagService tagService;
private final S3Client s3Client;
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
@Value("${app.s3.bucket}")
private String bucketName;
@@ -333,10 +332,7 @@ public class MassImportService {
if (tag != null) doc.getTags().add(tag);
doc.setMetadataComplete(metadataComplete);
Document saved = documentRepository.save(doc);
if (file.isPresent()) {
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
}
documentRepository.save(doc);
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
}

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.service;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -57,17 +58,6 @@ public class PersonService {
return personRepository.findAllById(ids);
}
public List<Person> findAllFamilyMembers() {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
}
@Transactional
public Person setFamilyMember(UUID personId, boolean familyMember) {
Person person = getById(personId);
person.setFamilyMember(familyMember);
return personRepository.save(person);
}
public Optional<Person> findByName(String firstName, String lastName) {
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
}
@@ -119,12 +109,8 @@ public class PersonService {
@Transactional
public Person createPerson(PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
}
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = Person.builder()
.personType(dto.getPersonType())
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
.firstName(dto.getFirstName())
.lastName(dto.getLastName())
@@ -150,13 +136,9 @@ public class PersonService {
@Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
}
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
person.setPersonType(dto.getPersonType());
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
person.setFirstName(dto.getFirstName());
person.setLastName(dto.getLastName());

View File

@@ -1,92 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Bridges document upload paths to asynchronous thumbnail generation. Use
* {@link #dispatchAfterCommit(UUID)} from inside {@code @Transactional} service methods —
* it registers a post-commit hook so the async task only fires when the surrounding
* transaction actually commits, and is silently skipped on rollback. Mirrors
* {@link org.raddatz.familienarchiv.audit.AuditService#logAfterCommit}.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ThumbnailAsyncRunner {
private final DocumentRepository documentRepository;
private final ThumbnailService thumbnailService;
/** Per-document timeout for the whole generate() call — defense against corrupt PDFs. */
private long generateTimeoutSeconds = 30L;
/**
* Registers a post-commit hook that triggers asynchronous thumbnail generation for the
* given document. When no transaction is active the task is dispatched immediately.
* Safe to call from inside {@code @Transactional} service methods.
*/
public void dispatchAfterCommit(UUID documentId) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
generateAsync(documentId);
}
});
} else {
generateAsync(documentId);
}
}
/**
* Runs thumbnail generation on the {@code thumbnailExecutor} pool, wrapped in a watchdog
* timeout so a hung PDFBox render cannot occupy a pool thread indefinitely. Never throws:
* all errors and timeouts are logged and swallowed so upload paths are not affected.
*/
@Async("thumbnailExecutor")
public void generateAsync(UUID documentId) {
Optional<Document> docOpt = documentRepository.findById(documentId);
if (docOpt.isEmpty()) {
log.warn("Thumbnail generation skipped: document not found id={}", documentId);
return;
}
Document doc = docOpt.get();
ExecutorService timeoutWorker = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "Thumbnail-Render-" + documentId);
t.setDaemon(true);
return t;
});
try {
Future<ThumbnailService.Outcome> future = timeoutWorker.submit(
() -> thumbnailService.generate(doc));
try {
future.get(generateTimeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
log.warn("Thumbnail generation timed out after {}s for doc={}",
generateTimeoutSeconds, documentId);
} catch (Exception e) {
log.warn("Thumbnail generation errored for doc={} reason={}",
documentId, e.getMessage());
}
} finally {
timeoutWorker.shutdownNow();
}
}
}

View File

@@ -1,104 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
/**
* Sequentially regenerates thumbnails for documents that have a file attached but no
* thumbnail yet. Runs on the {@code thumbnailExecutor} pool — single-threaded iteration
* is intentional: PDFBox + ImageIO are memory-heavy and we cap peak usage by processing
* documents one at a time. Only one backfill can run at a time; concurrent starts are
* rejected with {@link ErrorCode#THUMBNAIL_BACKFILL_ALREADY_RUNNING}.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ThumbnailBackfillService {
public enum State { IDLE, RUNNING, DONE, FAILED }
public record BackfillStatus(
State state,
String message,
int total,
int processed,
int skipped,
int failed,
LocalDateTime startedAt
) {}
private final DocumentRepository documentRepository;
private final ThumbnailService thumbnailService;
private volatile BackfillStatus currentStatus = new BackfillStatus(
State.IDLE, "Kein Backfill gestartet.", 0, 0, 0, 0, null);
public BackfillStatus getStatus() {
return currentStatus;
}
@Async("thumbnailExecutor")
public void runBackfillAsync() {
if (currentStatus.state() == State.RUNNING) {
throw DomainException.conflict(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING,
"Thumbnail-Backfill läuft bereits");
}
LocalDateTime startedAt = LocalDateTime.now();
List<Document> docs;
try {
docs = documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull();
} catch (Exception e) {
currentStatus = new BackfillStatus(State.FAILED,
"Backfill fehlgeschlagen: " + e.getMessage(),
0, 0, 0, 0, startedAt);
log.warn("Thumbnail backfill aborted before starting: {}", e.getMessage());
return;
}
int total = docs.size();
currentStatus = new BackfillStatus(State.RUNNING,
"Backfill läuft…", total, 0, 0, 0, startedAt);
log.info("Thumbnail backfill started: total={}", total);
int processed = 0;
int skipped = 0;
int failed = 0;
for (Document doc : docs) {
ThumbnailService.Outcome outcome;
try {
outcome = thumbnailService.generate(doc);
} catch (Exception e) {
log.warn("Thumbnail generation failed for doc={} reason={}",
doc.getId(), e.getMessage());
outcome = ThumbnailService.Outcome.FAILED;
}
switch (outcome) {
case SUCCESS -> processed++;
case SKIPPED -> skipped++;
case FAILED -> failed++;
}
currentStatus = new BackfillStatus(State.RUNNING,
"Backfill läuft…", total, processed, skipped, failed, startedAt);
}
long durationMs = Duration.between(startedAt, LocalDateTime.now()).toMillis();
log.info("Thumbnail backfill complete: total={} processed={} skipped={} failed={} durationMs={}",
total, processed, skipped, failed, durationMs);
currentStatus = new BackfillStatus(State.DONE,
String.format("Fertig: %d erzeugt, %d übersprungen, %d fehlgeschlagen.",
processed, skipped, failed),
total, processed, skipped, failed, startedAt);
}
}

View File

@@ -1,233 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.io.RandomAccessReadBuffer;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.ThumbnailAspect;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.UUID;
/**
* Generates JPEG thumbnail previews for documents (PDF first-page or scaled-down image)
* and uploads them to the S3 thumbnails/ prefix. Fire-and-forget from upload paths via
* {@link ThumbnailAsyncRunner}; also invoked by {@link ThumbnailBackfillService} for
* historical documents. Explicitly does not throw — failures are returned as
* {@link Outcome#FAILED} so the backfill can account for them without aborting the run.
*/
@Service
@Slf4j
public class ThumbnailService {
public enum Outcome { SUCCESS, SKIPPED, FAILED }
private static final int THUMBNAIL_WIDTH = 240;
private static final float JPEG_QUALITY = 0.85f;
private static final int PDF_RENDER_DPI = 100;
// Anything below this w/h ratio stays PORTRAIT — near-square A4 scans should
// render in the portrait tile rather than flipping to landscape at 1.01.
private static final float LANDSCAPE_THRESHOLD = 1.1f;
private static final String PDF_CONTENT_TYPE = "application/pdf";
private static final Set<String> IMAGE_CONTENT_TYPES =
Set.of("image/jpeg", "image/png", "image/tiff");
// Deterministic S3 key — `thumbnails/{docId}.jpg`. When a document's file is replaced
// the regenerated thumbnail overwrites this same key via PutObject, so we never
// orphan old thumbnails. The URL-level cache buster is the `thumbnail_generated_at`
// timestamp (see /api/documents/{id}/thumbnail ?v= param).
private static final String THUMBNAIL_KEY_PREFIX = "thumbnails/";
private static final String THUMBNAIL_KEY_SUFFIX = ".jpg";
private final FileService fileService;
private final S3Client s3Client;
private final DocumentRepository documentRepository;
@Value("${app.s3.bucket}")
private String bucketName;
public ThumbnailService(FileService fileService, S3Client s3Client,
DocumentRepository documentRepository) {
this.fileService = fileService;
this.s3Client = s3Client;
this.documentRepository = documentRepository;
}
public Outcome generate(Document doc) {
if (doc.getFilePath() == null) {
log.debug("Document {} has no filePath, skipping thumbnail", doc.getId());
return Outcome.SKIPPED;
}
String contentType = doc.getContentType();
if (contentType == null || !isSupported(contentType)) {
log.warn("Document {} has unsupported contentType {}, skipping thumbnail",
doc.getId(), contentType);
return Outcome.SKIPPED;
}
SourcePreview preview = readSourcePreview(doc, contentType);
if (preview == null
|| preview.image().getWidth() <= 0 || preview.image().getHeight() <= 0) {
log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId());
return Outcome.FAILED;
}
byte[] jpeg = encodeThumbnail(preview.image(), doc.getId());
if (jpeg == null) return Outcome.FAILED;
String thumbnailKey = thumbnailKeyFor(doc.getId());
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
ThumbnailResult result = new ThumbnailResult(
thumbnailKey, aspectOf(preview.image()), preview.pageCount());
return persistThumbnailMetadata(doc, result);
}
private static ThumbnailAspect aspectOf(BufferedImage source) {
float ratio = (float) source.getWidth() / source.getHeight();
return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT;
}
// First-page image + total page count for the source file. Page count is always
// 1 for image uploads; for PDFs it comes straight from PDDocument.
private record SourcePreview(BufferedImage image, int pageCount) {}
// Everything the generate pipeline has already committed to storage and
// now wants stamped onto the Document entity in a single save call.
private record ThumbnailResult(String key, ThumbnailAspect aspect, int pageCount) {}
private static String thumbnailKeyFor(UUID documentId) {
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
}
private SourcePreview readSourcePreview(Document doc, String contentType) {
try {
return PDF_CONTENT_TYPE.equals(contentType)
? renderPdfFirstPage(doc.getFilePath())
: new SourcePreview(readImage(doc.getFilePath()), 1);
} catch (Exception e) {
log.warn("Thumbnail source read failed for doc={} reason={}",
doc.getId(), e.getMessage());
return null;
}
}
private byte[] encodeThumbnail(BufferedImage source, UUID documentId) {
try {
BufferedImage scaled = scaleToWidth(source, THUMBNAIL_WIDTH);
return encodeJpeg(scaled, JPEG_QUALITY);
} catch (Exception e) {
log.warn("Thumbnail JPEG encoding failed for doc={} reason={}",
documentId, e.getMessage());
return null;
}
}
private boolean uploadToStorage(String thumbnailKey, byte[] jpeg, UUID documentId) {
try {
s3Client.putObject(
PutObjectRequest.builder()
.bucket(bucketName)
.key(thumbnailKey)
.contentType("image/jpeg")
.build(),
RequestBody.fromBytes(jpeg));
return true;
} catch (Exception e) {
log.warn("Thumbnail upload failed for doc={} key={} reason={}",
documentId, thumbnailKey, e.getMessage());
return false;
}
}
private Outcome persistThumbnailMetadata(Document doc, ThumbnailResult result) {
try {
doc.setThumbnailKey(result.key());
doc.setThumbnailGeneratedAt(LocalDateTime.now());
doc.setThumbnailAspect(result.aspect());
doc.setPageCount(result.pageCount());
documentRepository.save(doc);
return Outcome.SUCCESS;
} catch (Exception e) {
// Thumbnail is already in S3 but the entity update failed. Because the S3
// key is deterministic (thumbnails/{docId}.jpg), the next successful run
// — either a re-upload of this document or the admin backfill — will
// overwrite it cleanly. Logging distinctly so an operator tracking
// backfill totals can spot the database-side issue.
log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}",
doc.getId(), result.key(), e.getMessage());
return Outcome.FAILED;
}
}
private boolean isSupported(String contentType) {
return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType);
}
private SourcePreview renderPdfFirstPage(String s3Key) throws IOException {
try (InputStream in = fileService.downloadFileStream(s3Key);
PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) {
PDFRenderer renderer = new PDFRenderer(pdf);
BufferedImage image = renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB);
return new SourcePreview(image, pdf.getNumberOfPages());
}
}
private BufferedImage readImage(String s3Key) throws IOException {
try (InputStream in = fileService.downloadFileStream(s3Key)) {
BufferedImage img = ImageIO.read(in);
if (img == null) {
throw new IOException("No ImageIO reader available for " + s3Key);
}
return img;
}
}
private BufferedImage scaleToWidth(BufferedImage source, int targetWidth) {
int sourceWidth = source.getWidth();
int sourceHeight = source.getHeight();
int targetHeight = Math.max(1, Math.round((float) targetWidth * sourceHeight / sourceWidth));
BufferedImage scaled = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
g.dispose();
return scaled;
}
private byte[] encodeJpeg(BufferedImage image, float quality) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(quality);
try (ImageOutputStream out = ImageIO.createImageOutputStream(bos)) {
writer.setOutput(out);
writer.write(null, new IIOImage(image, null, null), param);
} finally {
writer.dispose();
}
return bos.toByteArray();
}
}

View File

@@ -1,27 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.repository.CompletionStatsRow;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class TranscriptionBlockQueryService {
private final TranscriptionBlockRepository blockRepository;
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
if (documentIds.isEmpty()) return Map.of();
Map<UUID, Integer> result = new HashMap<>();
for (CompletionStatsRow row : blockRepository.findCompletionStatsForDocuments(documentIds)) {
result.put(row.getDocumentId(), row.getCompletionPercentage());
}
return result;
}
}

View File

@@ -134,8 +134,6 @@ public class TranscriptionService {
if (dto.getLabel() != null) {
block.setLabel(dto.getLabel());
}
block.getMentionedPersons().clear();
block.getMentionedPersons().addAll(dto.getMentionedPersons());
block.setUpdatedBy(userId);
TranscriptionBlock saved = blockRepository.save(block);
@@ -207,18 +205,6 @@ public class TranscriptionService {
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) {
getBlock(documentId, blockId);
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);

View File

@@ -3,8 +3,6 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
@@ -23,13 +21,10 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static java.util.stream.Collectors.toSet;
@Service
@RequiredArgsConstructor
@Slf4j
@@ -38,10 +33,9 @@ public class UserService {
private final AppUserRepository userRepository;
private final UserGroupRepository groupRepository;
private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
@Transactional
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
public AppUser createUserOrUpdate(CreateUserRequest request) {
log.info("Creating or updating user: {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>();
@@ -51,12 +45,10 @@ public class UserService {
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
AppUser user;
boolean isNew;
if (existingUser.isPresent()) {
log.info("User exists, updating: {}", request.getEmail());
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
isNew = false;
} else {
log.info("Creating new user: {}", request.getEmail());
user = AppUser.builder()
@@ -69,42 +61,8 @@ public class UserService {
.contact(request.getContact())
.enabled(true)
.build();
isNew = true;
}
AppUser saved = userRepository.save(user);
if (isNew) {
auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null,
Map.of("userId", saved.getId().toString(), "email", saved.getEmail()));
}
return saved;
}
@Transactional
public AppUser createUserForBootstrap(CreateUserRequest request) {
log.info("Bootstrap user creation (no audit): {}", request.getEmail());
Set<UserGroup> groups = new HashSet<>();
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
}
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
if (existingUser.isPresent()) {
AppUser updated = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
return userRepository.save(updated);
}
AppUser user = AppUser.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getInitialPassword()))
.groups(groups)
.firstName(request.getFirstName())
.lastName(request.getLastName())
.birthDate(request.getBirthDate())
.contact(request.getContact())
.enabled(true)
.build();
return userRepository.save(user);
}
@@ -136,13 +94,10 @@ public class UserService {
}
@Transactional
public void deleteUser(UUID actorId, UUID userId) {
public void deleteUser(UUID userId) {
AppUser user = userRepository.findById(userId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
String email = user.getEmail();
userRepository.delete(user);
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
Map.of("userId", userId.toString(), "email", email));
}
public AppUser getById(UUID id) {
@@ -186,7 +141,7 @@ public class UserService {
}
@Transactional
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
AppUser user = getById(id);
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
@@ -211,27 +166,13 @@ public class UserService {
}
if (dto.getGroupIds() != null) {
Set<UserGroup> before = new HashSet<>(user.getGroups());
Set<UserGroup> after = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
user.setGroups(after);
groupChangePayload(before, after, id, user.getEmail())
.ifPresent(payload -> auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, payload));
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
user.setGroups(groups);
}
return userRepository.save(user);
}
private Optional<Map<String, Object>> groupChangePayload(
Set<UserGroup> before, Set<UserGroup> after, UUID userId, String email) {
Set<UUID> beforeIds = before.stream().map(UserGroup::getId).collect(toSet());
Set<UUID> afterIds = after.stream().map(UserGroup::getId).collect(toSet());
if (beforeIds.equals(afterIds)) return Optional.empty();
List<String> added = after.stream().filter(g -> !beforeIds.contains(g.getId())).map(UserGroup::getName).toList();
List<String> removed = before.stream().filter(g -> !afterIds.contains(g.getId())).map(UserGroup::getName).toList();
return Optional.of(Map.of("userId", userId.toString(), "email", email,
"addedGroups", added, "removedGroups", removed));
}
@Transactional
public void changePassword(UUID userId, ChangePasswordDTO dto) {
AppUser user = getById(userId);

View File

@@ -23,8 +23,7 @@ spring:
servlet:
multipart:
max-file-size: 50MB
max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317
file-size-threshold: 2KB
max-request-size: 50MB
mail:
host: ${MAIL_HOST:}

View File

@@ -1,2 +0,0 @@
CREATE INDEX IF NOT EXISTS idx_transcription_blocks_document_reviewed
ON transcription_blocks (document_id, reviewed);

View File

@@ -1,7 +0,0 @@
-- Partial covering index for the session-style activity feed rollup (#285).
-- Matches the WHERE clause of AuditLogQueryRepository.findRolledUpActivityFeed
-- exactly. DESC on happened_at supports the outer ORDER BY without a sort step.
CREATE INDEX idx_audit_log_rollup
ON audit_log (actor_id, document_id, kind, happened_at DESC)
WHERE kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED',
'BLOCK_REVIEWED','COMMENT_ADDED','MENTION_CREATED');

View File

@@ -1,39 +0,0 @@
-- Backfill COMMENT_ADDED and MENTION_CREATED audit events for comments
-- created before audit logging was added in commit 428c63a2.
-- Without these rows the Chronik activity feed (which reads exclusively from
-- audit_log) cannot surface pre-existing comments in "Für dich" or "Alle".
INSERT INTO audit_log (id, happened_at, actor_id, kind, document_id, payload)
SELECT
gen_random_uuid(),
c.created_at,
c.author_id,
'COMMENT_ADDED',
c.document_id,
jsonb_build_object('commentId', c.id::text)
FROM document_comments c
WHERE NOT EXISTS (
SELECT 1 FROM audit_log a
WHERE a.kind = 'COMMENT_ADDED'
AND a.payload->>'commentId' = c.id::text
);
INSERT INTO audit_log (id, happened_at, actor_id, kind, document_id, payload)
SELECT
gen_random_uuid(),
c.created_at,
c.author_id,
'MENTION_CREATED',
c.document_id,
jsonb_build_object(
'commentId', c.id::text,
'mentionedUserId', m.user_id::text
)
FROM comment_mentions m
JOIN document_comments c ON c.id = m.comment_id
WHERE NOT EXISTS (
SELECT 1 FROM audit_log a
WHERE a.kind = 'MENTION_CREATED'
AND a.payload->>'commentId' = c.id::text
AND a.payload->>'mentionedUserId' = m.user_id::text
);

View File

@@ -1,24 +0,0 @@
-- Backfill annotation_id on block comments and their notifications.
--
-- Before the upstream fix, CommentService.postBlockComment did not set
-- DocumentComment.annotationId, so block comments were stored with
-- annotation_id = NULL and every notification built from them inherited
-- that NULL (see NotificationService.notifyMentions/notifyReply).
--
-- The frontend deep-link flow needs annotationId in the URL query string
-- to open the correct annotation panel and scroll to the comment.
-- Without this backfill, previously issued notifications would still
-- carry annotation_id = NULL even after the code fix lands.
UPDATE document_comments dc
SET annotation_id = tb.annotation_id
FROM transcription_blocks tb
WHERE dc.block_id = tb.id
AND dc.annotation_id IS NULL;
UPDATE notifications n
SET annotation_id = dc.annotation_id
FROM document_comments dc
WHERE n.reference_id = dc.id
AND n.annotation_id IS NULL
AND dc.annotation_id IS NOT NULL;

View File

@@ -1,3 +0,0 @@
ALTER TABLE documents
ADD COLUMN thumbnail_key VARCHAR(255),
ADD COLUMN thumbnail_generated_at TIMESTAMP;

View File

@@ -1,8 +0,0 @@
-- Adds two nullable metadata columns populated by ThumbnailService when it
-- generates the JPEG preview: thumbnail_aspect (PORTRAIT | LANDSCAPE, from the
-- source image w/h ratio with threshold 1.1) and page_count (from PDDocument
-- for PDFs, 1 for image uploads). Both are null until the existing admin
-- backfill endpoint (/api/admin/generate-thumbnails) reruns the service.
ALTER TABLE documents
ADD COLUMN thumbnail_aspect VARCHAR(16),
ADD COLUMN page_count INTEGER;

View File

@@ -1,30 +0,0 @@
-- Family network: marks a Person as a tree node and stores typed relationships
-- between two persons. The tree page (/stammbaum) only shows persons with
-- family_member = TRUE. Symmetric types (SPOUSE_OF, SIBLING_OF) are stored once;
-- the partial unique index keeps SIBLING_OF pairs from being duplicated in the
-- reverse direction.
ALTER TABLE persons
ADD COLUMN family_member BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE person_relationships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
related_person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
relation_type VARCHAR(30) NOT NULL,
from_year INTEGER,
to_year INTEGER,
notes VARCHAR(2000),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT no_self_rel CHECK (person_id <> related_person_id),
CONSTRAINT unique_rel UNIQUE (person_id, related_person_id, relation_type)
);
CREATE INDEX idx_person_rel_person_id ON person_relationships(person_id);
CREATE INDEX idx_person_rel_related_person_id ON person_relationships(related_person_id);
-- Symmetric SIBLING_OF: enforce only one row per unordered pair.
CREATE UNIQUE INDEX unique_sibling_pair ON person_relationships (
LEAST(person_id, related_person_id),
GREATEST(person_id, related_person_id)
) WHERE relation_type = 'SIBLING_OF';

View File

@@ -1,6 +0,0 @@
-- Symmetric SPOUSE_OF: enforce only one row per unordered pair, mirroring the
-- SIBLING_OF index added in V54.
CREATE UNIQUE INDEX unique_spouse_pair ON person_relationships (
LEAST(person_id, related_person_id),
GREATEST(person_id, related_person_id)
) WHERE relation_type = 'SPOUSE_OF';

View File

@@ -1,25 +0,0 @@
-- Sidecar table for @-mentions inside transcription_blocks.text.
-- Each row is one (block_id, person_id, display_name) tuple emitted by the
-- typeahead in the transcription editor. block.text contains the literal
-- "@DisplayName" — the UUID lives only here so historical text stays clean.
--
-- Schema choice: child table via @ElementCollection (mirrors the established
-- UserGroup.permissions / group_permissions pattern), NOT JSONB. The "show
-- all blocks mentioning person X" query on the person detail page joins on
-- the indexed person_id column — equally fast as JSONB GIN containment, no
-- new dependency. document_comments.comment_mentions stays as a many-to-many
-- to AppUser; the divergence is intentional: Person mentions need lazy
-- degradation when a person is deleted (no FK), while user mentions don't.
--
-- No FK on person_id: when a Person is deleted we want @Auguste Raddatz to
-- remain visible as plain unlinked text inside the transcription rather than
-- vanishing or cascade-deleting the block.
CREATE TABLE transcription_block_mentioned_persons (
block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE,
person_id UUID NOT NULL,
display_name VARCHAR(200) NOT NULL
);
CREATE INDEX idx_tbmp_person_id ON transcription_block_mentioned_persons(person_id);
CREATE INDEX idx_tbmp_block_id ON transcription_block_mentioned_persons(block_id);

View File

@@ -1,5 +0,0 @@
-- Prevent duplicate sidecar rows for the same (block, person) pair.
-- @ElementCollection uses DELETE+INSERT per update so normal JPA writes can't
-- create duplicates, but a raw-SQL import or concurrent bypass of JPA could.
ALTER TABLE transcription_block_mentioned_persons
ADD CONSTRAINT uq_tbmp_block_person UNIQUE (block_id, person_id);

View File

@@ -1,74 +0,0 @@
package org.raddatz.familienarchiv.audit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.model.AppUser;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AuditLogQueryServiceTest {
@Mock AuditLogQueryRepository queryRepository;
@InjectMocks AuditLogQueryService auditLogQueryService;
@Test
void findActivityFeed_withKinds_passesKindNamesToRepository() {
UUID userId = UUID.randomUUID();
Set<AuditKind> kinds = Set.of(AuditKind.FILE_UPLOADED);
when(queryRepository.findRolledUpActivityFeed(eq(userId.toString()), eq(10), anyCollection()))
.thenReturn(List.of());
List<ActivityFeedRow> result = auditLogQueryService.findActivityFeed(userId, 10, kinds);
assertThat(result).isEmpty();
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
eq(List.of("FILE_UPLOADED")));
}
@Test
void findActivityFeed_twoArg_defaultsToAllRollupEligibleKinds() {
UUID userId = UUID.randomUUID();
when(queryRepository.findRolledUpActivityFeed(eq(userId.toString()), eq(10), anyCollection()))
.thenReturn(List.of());
auditLogQueryService.findActivityFeed(userId, 10);
verify(queryRepository).findRolledUpActivityFeed(eq(userId.toString()), eq(10),
eq(AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList()));
}
@Test
void findRecentUserManagementEvents_delegatesToRepositoryWithAllThreeKinds() {
AuditLog entry = AuditLog.builder().id(UUID.randomUUID()).kind(AuditKind.USER_CREATED).build();
when(queryRepository.findByKindIn(anyCollection(), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(entry)));
List<AuditLog> result = auditLogQueryService.findRecentUserManagementEvents(5);
assertThat(result).containsExactly(entry);
verify(queryRepository).findByKindIn(
argThat((Collection<AuditKind> kinds) ->
kinds.contains(AuditKind.USER_CREATED) &&
kinds.contains(AuditKind.USER_DELETED) &&
kinds.contains(AuditKind.GROUP_MEMBERSHIP_CHANGED)),
any(Pageable.class));
}
}

View File

@@ -1,122 +0,0 @@
package org.raddatz.familienarchiv.audit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.GroupDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.support.TransactionTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class UserManagementAuditIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired UserService userService;
@Autowired AppUserRepository userRepository;
@Autowired AuditLogRepository auditLogRepository;
@Autowired AuditLogQueryService auditLogQueryService;
@Autowired TransactionTemplate transactionTemplate;
@BeforeEach
void clearAuditLog() {
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
}
@Test
void createAndDeleteUser_producesOrderedAuditEntries() {
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
CreateUserRequest adminReq = new CreateUserRequest();
adminReq.setEmail("admin@test.example.com");
adminReq.setInitialPassword("admin-secret");
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(adminReq));
UUID actorId = actor.getId();
// Create the target user — should emit USER_CREATED
CreateUserRequest req = new CreateUserRequest();
req.setEmail("audit-test@example.com");
req.setInitialPassword("secret");
transactionTemplate.execute(status -> {
userService.createUserOrUpdate(actorId, req);
return null;
});
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
// Delete the target user — should emit USER_DELETED
AppUser created = userRepository.findByEmail("audit-test@example.com").orElseThrow();
transactionTemplate.execute(status -> {
userService.deleteUser(actorId, created.getId());
return null;
});
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_DELETED));
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
assertThat(events).hasSize(2);
assertThat(events.get(0).getKind()).isEqualTo(AuditKind.USER_DELETED);
assertThat(events.get(1).getKind()).isEqualTo(AuditKind.USER_CREATED);
}
@Test
void updateUserGroups_producesGroupMembershipChangedEvent() {
GroupDTO groupADto = new GroupDTO(); groupADto.setName("Viewers"); groupADto.setPermissions(Set.of("READ_ALL"));
GroupDTO groupBDto = new GroupDTO(); groupBDto.setName("Editors"); groupBDto.setPermissions(Set.of("WRITE_ALL"));
UserGroup gA = transactionTemplate.execute(status -> userService.createGroup(groupADto));
UserGroup gB = transactionTemplate.execute(status -> userService.createGroup(groupBDto));
// Bootstrap actor with no audit event — clean slate guaranteed by @BeforeEach
CreateUserRequest actorReq = new CreateUserRequest();
actorReq.setEmail("actor-group-test@test.example.com");
actorReq.setInitialPassword("secret");
AppUser actor = transactionTemplate.execute(status -> userService.createUserForBootstrap(actorReq));
// Create target user pre-assigned to gA — emits USER_CREATED
CreateUserRequest targetReq = new CreateUserRequest();
targetReq.setEmail("target-group-test@test.example.com");
targetReq.setInitialPassword("secret");
targetReq.setGroupIds(List.of(gA.getId()));
transactionTemplate.execute(status -> userService.createUserOrUpdate(actor.getId(), targetReq));
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.USER_CREATED));
transactionTemplate.execute(status -> { auditLogRepository.deleteAll(); return null; });
AppUser target = userRepository.findByEmail("target-group-test@test.example.com").orElseThrow();
// Change groups: Viewers → Editors
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of(gB.getId()));
transactionTemplate.execute(status -> userService.adminUpdateUser(actor.getId(), target.getId(), dto));
await().atMost(10, SECONDS).until(() -> auditLogRepository.existsByKind(AuditKind.GROUP_MEMBERSHIP_CHANGED));
List<AuditLog> events = auditLogQueryService.findRecentUserManagementEvents(10);
assertThat(events).hasSize(1);
AuditLog event = events.get(0);
assertThat(event.getKind()).isEqualTo(AuditKind.GROUP_MEMBERSHIP_CHANGED);
assertThat(event.getPayload()).containsEntry("email", "target-group-test@test.example.com");
@SuppressWarnings("unchecked")
List<String> added = (List<String>) event.getPayload().get("addedGroups");
@SuppressWarnings("unchecked")
List<String> removed = (List<String>) event.getPayload().get("removedGroups");
assertThat(added).containsExactlyInAnyOrder("Editors");
assertThat(removed).containsExactlyInAnyOrder("Viewers");
}
}

View File

@@ -8,7 +8,6 @@ import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
import org.raddatz.familienarchiv.service.MassImportService;
import org.raddatz.familienarchiv.service.ThumbnailBackfillService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -17,13 +16,10 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -37,7 +33,6 @@ class AdminControllerTest {
@MockitoBean MassImportService massImportService;
@MockitoBean DocumentService documentService;
@MockitoBean DocumentVersionService documentVersionService;
@MockitoBean ThumbnailBackfillService thumbnailBackfillService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@Test
@@ -88,57 +83,4 @@ class AdminControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(3));
}
// ─── POST /api/admin/generate-thumbnails ───────────────────────────────────
@Test
void generateThumbnails_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/generate-thumbnails"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void generateThumbnails_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/admin/generate-thumbnails"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ADMIN")
void generateThumbnails_returns202_withStatus_whenAdmin() throws Exception {
ThumbnailBackfillService.BackfillStatus status = new ThumbnailBackfillService.BackfillStatus(
ThumbnailBackfillService.State.RUNNING, "running…", 10, 0, 0, 0, LocalDateTime.now());
when(thumbnailBackfillService.getStatus()).thenReturn(status);
mockMvc.perform(post("/api/admin/generate-thumbnails"))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.state").value("RUNNING"))
.andExpect(jsonPath("$.total").value(10));
verify(thumbnailBackfillService).runBackfillAsync();
}
// ─── GET /api/admin/thumbnail-status ───────────────────────────────────────
@Test
void thumbnailStatus_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/admin/thumbnail-status"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ADMIN")
void thumbnailStatus_returns200_withCurrentStatus_whenAdmin() throws Exception {
ThumbnailBackfillService.BackfillStatus status = new ThumbnailBackfillService.BackfillStatus(
ThumbnailBackfillService.State.DONE, "Fertig: 5 erzeugt, 0 übersprungen, 0 fehlgeschlagen.",
5, 5, 0, 0, LocalDateTime.now());
when(thumbnailBackfillService.getStatus()).thenReturn(status);
mockMvc.perform(get("/api/admin/thumbnail-status"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.state").value("DONE"))
.andExpect(jsonPath("$.processed").value(5))
.andExpect(jsonPath("$.total").value(5));
}
}

View File

@@ -154,13 +154,6 @@ class AnnotationControllerTest {
.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
@WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {

View File

@@ -40,8 +40,246 @@ class CommentControllerTest {
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
private static final UUID DOC_ID = UUID.randomUUID();
private static final UUID ANN_ID = UUID.randomUUID();
private static final UUID COMMENT_ID = UUID.randomUUID();
// ─── GET /api/documents/{documentId}/comments ─────────────────────────────
@Test
void getDocumentComments_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getDocumentComments_returns200_whenAuthenticated() throws Exception {
when(commentService.getCommentsForDocument(any())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
.andExpect(status().isOk());
}
// ─── POST /api/documents/{documentId}/comments ────────────────────────────
@Test
void postDocumentComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void postDocumentComment_returns403_whenMissingPermission() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postDocumentComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.content").value("Test comment"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postDocumentComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
@Test
void replyToComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
.authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void replyToComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
.authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── PATCH /api/documents/{documentId}/comments/{commentId} ──────────────
@Test
void editComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void editComment_returns200_whenHasPermission() throws Exception {
DocumentComment updated = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk());
}
// ─── DELETE /api/documents/{documentId}/comments/{commentId} ─────────────
@Test
void deleteComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteComment_returns204_whenAuthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isNoContent());
}
// ─── GET /api/documents/{documentId}/annotations/{annId}/comments ─────────
@Test
void getAnnotationComments_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getAnnotationComments_returns200_whenAuthenticated() throws Exception {
when(commentService.getCommentsForAnnotation(any())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void editComment_returns200_whenHasWriteAllPermission() throws Exception {
DocumentComment updated = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk());
}
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
@Test
@WithMockUser
void postAnnotationComment_returns403_whenMissingPermission() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postAnnotationComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments/{commentId}/replies ─
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToAnnotationComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void replyToAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── resolveUser — exception branch ──────────────────────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postDocumentComment_stillSucceeds_whenUserServiceThrows() throws Exception {
// findByEmail throws → catch block in resolveUser → author null, saves anyway
when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error"));
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── Block comment endpoints ─────────────────────────────────────────────
@Test
@@ -67,138 +305,4 @@ class CommentControllerTest {
.andExpect(status().isCreated())
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
}
@Test
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void postBlockComment_returns403_whenMissingPermission() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception {
UUID blockId = UUID.randomUUID();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postBlockComment_stillSucceeds_whenUserServiceThrows() throws Exception {
// findByEmail throws → catch block in resolveUser → author null, saves anyway
UUID blockId = UUID.randomUUID();
when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error"));
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── Block reply endpoints ───────────────────────────────────────────────
@Test
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
+ "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
UUID blockId = UUID.randomUUID();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).parentId(COMMENT_ID)
.authorName("Anna").content("Reply").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
+ "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void replyToBlockComment_returns201_whenHasWriteAllPermission() throws Exception {
UUID blockId = UUID.randomUUID();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).parentId(COMMENT_ID)
.authorName("Anna").content("Reply").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
+ "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── PATCH /api/documents/{documentId}/comments/{commentId} (shared edit) ──
@Test
void editComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void editComment_returns200_whenHasPermission() throws Exception {
DocumentComment updated = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void editComment_returns200_whenHasWriteAllPermission() throws Exception {
DocumentComment updated = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk());
}
// ─── DELETE /api/documents/{documentId}/comments/{commentId} (shared) ────
@Test
void deleteComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteComment_returns204_whenAuthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isNoContent());
}
}

View File

@@ -1,17 +1,15 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
@@ -27,12 +25,10 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
import org.raddatz.familienarchiv.dto.SearchMatchData;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -44,7 +40,6 @@ import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -71,7 +66,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
@@ -81,13 +76,13 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_withStatusParam_passesItToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk());
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any());
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any());
}
@Test
@@ -114,18 +109,18 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_responseContainsTotalCount() throws Exception {
when(documentService.searchDocuments(any(), 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()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(0))
.andExpect(jsonPath("$.items").isArray());
.andExpect(jsonPath("$.total").value(0))
.andExpect(jsonPath("$.documents").isArray());
}
@Test
@WithMockUser
void search_responseBodyItemsContainMatchData() throws Exception {
void search_responseBodyContainsMatchDataKey() throws Exception {
UUID docId = UUID.randomUUID();
Document doc = Document.builder()
.id(docId)
@@ -133,82 +128,18 @@ class DocumentControllerTest {
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.build();
var matchData = new SearchMatchData(
var matchData = new org.raddatz.familienarchiv.dto.SearchMatchData(
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData)));
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items[0].matchData.transcriptionSnippet")
.andExpect(jsonPath("$.matchData").isMap())
.andExpect(jsonPath("$.matchData." + docId + ".transcriptionSnippet")
.value("Er schrieb einen langen Brief"));
}
// ─── /api/documents/search pagination ─────────────────────────────────────
@Test
@WithMockUser
void search_responseExposesPagingFields() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.pageNumber").exists())
.andExpect(jsonPath("$.pageSize").exists())
.andExpect(jsonPath("$.totalPages").exists())
.andExpect(jsonPath("$.totalElements").exists());
}
@Test
@WithMockUser
void search_returns400_whenSizeExceedsMax() throws Exception {
// Locks @Validated on the controller — removing it silently reopens the
// DoS window where a client could request all 1500 docs + enrichment.
mockMvc.perform(get("/api/documents/search").param("size", "101"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_returns400_whenSizeBelowMin() throws Exception {
mockMvc.perform(get("/api/documents/search").param("size", "0"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_returns400_whenPageNegative() throws Exception {
mockMvc.perform(get("/api/documents/search").param("page", "-1"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_returns400_whenPageAboveMax() throws Exception {
// Guards against page * size overflow into negative SQL OFFSET
mockMvc.perform(get("/api/documents/search").param("page", "200000"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_passesPageRequestToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
.andExpect(status().isOk());
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture());
org.springframework.data.domain.Pageable pageable = captor.getValue();
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
}
// ─── POST /api/documents ─────────────────────────────────────────────────
@Test
@@ -428,62 +359,6 @@ class DocumentControllerTest {
.andExpect(status().isNotFound());
}
// ─── GET /api/documents/{id}/thumbnail ───────────────────────────────────
@Test
void getDocumentThumbnail_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/thumbnail"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getDocumentThumbnail_returns404_whenDocHasNoThumbnail() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf").build();
when(documentService.getDocumentById(id)).thenReturn(doc);
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
.andExpect(status().isNotFound());
}
@Test
@WithMockUser
void getDocumentThumbnail_returns200_withPrivateCacheHeader() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf")
.thumbnailKey("thumbnails/" + id + ".jpg").build();
when(documentService.getDocumentById(id)).thenReturn(doc);
java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
when(fileService.downloadFile("thumbnails/" + id + ".jpg"))
.thenReturn(new FileService.S3FileDownload(
new org.springframework.core.io.InputStreamResource(stream), "image/jpeg"));
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type", "image/jpeg"))
.andExpect(header().string("Cache-Control",
org.hamcrest.Matchers.containsString("private")))
.andExpect(header().string("Cache-Control",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("public"))))
.andExpect(header().string("Cache-Control",
org.hamcrest.Matchers.containsString("immutable")));
}
@Test
@WithMockUser
void getDocumentThumbnail_returns404_whenStorageObjectMissing() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("t").originalFilename("f.pdf")
.thumbnailKey("thumbnails/" + id + ".jpg").build();
when(documentService.getDocumentById(id)).thenReturn(doc);
when(fileService.downloadFile("thumbnails/" + id + ".jpg"))
.thenThrow(new FileService.StorageFileNotFoundException("not found"));
mockMvc.perform(get("/api/documents/" + id + "/thumbnail"))
.andExpect(status().isNotFound());
}
// ─── POST /api/documents/quick-upload — null/empty files ─────────────────
@Test
@@ -505,7 +380,7 @@ class DocumentControllerTest {
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
@WithMockUser
void getIncompleteCount_returns200_withCount() throws Exception {
when(documentService.getIncompleteCount()).thenReturn(3L);
@@ -514,52 +389,14 @@ class DocumentControllerTest {
.andExpect(jsonPath("$.count").value(3));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getIncompleteCount_returns403_forReaderOnly() throws Exception {
mockMvc.perform(get("/api/documents/incomplete-count"))
.andExpect(status().isForbidden());
}
// ─── GET /api/documents/incomplete ───────────────────────────────────────
// ─── GET /api/documents/incomplete (removed — superseded by dashboard) ────
@Test
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
@WithMockUser
void getIncomplete_endpointRemoved() throws Exception {
// The path hits /{id} and fails UUID conversion — not a 200 anymore
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = {"WRITE_ALL"})
void getIncomplete_returns200_forWriter_withDTOList() throws Exception {
UUID id = UUID.randomUUID();
java.time.LocalDateTime uploadedAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0);
var dto = new org.raddatz.familienarchiv.dto.IncompleteDocumentDTO(id, "Unvollständig", uploadedAt);
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(id.toString()))
.andExpect(jsonPath("$[0].title").value("Unvollständig"))
.andExpect(jsonPath("$[0].uploadedAt").exists());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getIncomplete_returns403_forReaderOnly() throws Exception {
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getIncomplete_capsSizeAt200() throws Exception {
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/incomplete").param("size", "9999"))
.andExpect(status().isOk());
verify(documentService).findIncompleteDocuments(200);
.andExpect(status().is4xxClientError());
}
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
@@ -572,7 +409,7 @@ class DocumentControllerTest {
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
@WithMockUser
void getNextIncomplete_returns200_whenNextExists() throws Exception {
UUID excludeId = UUID.randomUUID();
Document next = Document.builder()
@@ -586,15 +423,7 @@ class DocumentControllerTest {
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getNextIncomplete_returns403_forReaderOnly() throws Exception {
mockMvc.perform(get("/api/documents/incomplete/next")
.param("excludeId", UUID.randomUUID().toString()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
@WithMockUser
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
UUID excludeId = UUID.randomUUID();
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
@@ -768,476 +597,4 @@ class DocumentControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.editorName").value("Otto"));
}
// ─── POST /api/documents/quick-upload — metadata part ────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception {
UUID senderId = UUID.randomUUID();
Person sender = Person.builder().id(senderId).lastName("Müller").build();
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build();
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build();
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
.thenReturn(new DocumentService.StoreResult(doc1, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
.thenReturn(new DocumentService.StoreResult(doc2, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
.thenReturn(new DocumentService.StoreResult(doc3, true));
org.springframework.mock.web.MockMultipartFile f1 =
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f2 =
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f3 =
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
("{\"senderId\":\"" + senderId + "\"}").getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created.length()").value(3))
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.updated").isEmpty())
.andExpect(jsonPath("$.errors").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception {
UUID senderId = UUID.randomUUID();
Person sender = Person.builder().id(senderId).lastName("Müller").build();
Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
.thenReturn(new DocumentService.StoreResult(existing, false));
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
("{\"senderId\":\"" + senderId + "\"}").getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.errors").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception {
Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
.thenReturn(new DocumentService.StoreResult(docA, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
.thenReturn(new DocumentService.StoreResult(docB, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
.thenReturn(new DocumentService.StoreResult(docC, true));
org.springframework.mock.web.MockMultipartFile f1 =
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f2 =
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f3 =
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
.andExpect(jsonPath("$.created[1].title").value("Beta"))
.andExpect(jsonPath("$.created[2].title").value("Gamma"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.Mockito.doThrow(
org.raddatz.familienarchiv.exception.DomainException.badRequest(
org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
.when(documentService).validateBatch(eq(2), any());
org.springframework.mock.web.MockMultipartFile f1 =
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f2 =
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception {
Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.ArgumentCaptor<DocumentBatchMetadataDTO> captor =
org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class);
when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any()))
.thenReturn(new DocumentService.StoreResult(doc, true));
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
.andExpect(status().isOk());
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
.containsExactly("Briefwechsel", "Krieg");
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.Mockito.doThrow(
org.raddatz.familienarchiv.exception.DomainException.badRequest(
org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
.when(documentService).validateBatch(eq(51), any());
var builder = multipart("/api/documents/quick-upload");
for (int i = 0; i < 51; i++) {
builder.file(new org.springframework.mock.web.MockMultipartFile(
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
}
mockMvc.perform(builder)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
}
// ─── PATCH /api/documents/bulk ───────────────────────────────────────────
private static String bulkBody(String... uuids) {
StringBuilder sb = new StringBuilder("{\"documentIds\":[");
for (int i = 0; i < uuids.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(uuids[i]).append("\"");
}
sb.append("]}");
return sb.toString();
}
@Test
void patchBulk_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(UUID.randomUUID().toString())))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void patchBulk_returns403_forReadAllUser() throws Exception {
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(UUID.randomUUID().toString())))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"documentIds\":[]}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenDocumentIdsExceedsCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
String[] ids = new String[501];
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(ids)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenArchiveBoxExceeds255Chars() throws Exception {
// Tobias C2 — DocumentBulkEditDTO.archiveBox carries @Size(max=255).
// Without @Valid on @RequestBody this would silently land an
// arbitrarily long string; the test pins both the annotation and
// the controller-level @Valid wiring.
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
String tooLong = "x".repeat(256);
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_acceptsExactly500Ids_atTheCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.applyBulkEditToDocument(any(), any(), any()))
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
String[] ids = new String[500];
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(ids)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(500));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(id), any(), any()))
.thenAnswer(inv -> Document.builder().id(id).build());
// Same id sent three times — controller should dedupe and call the
// service exactly once, returning updated=1, not 3.
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(id.toString(), id.toString(), id.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(1));
verify(documentService, org.mockito.Mockito.times(1))
.applyBulkEditToDocument(eq(id), any(), any());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns200_andCallsServiceForEachId() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(any(), any(), any()))
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(id1.toString(), id2.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(2))
.andExpect(jsonPath("$.errors").isEmpty());
verify(documentService).applyBulkEditToDocument(eq(id1), any(), any());
verify(documentService).applyBulkEditToDocument(eq(id2), any(), any());
}
// ─── GET /api/documents/ids ──────────────────────────────────────────────
@Test
void getDocumentIds_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getDocumentIds_returns403_forUserWithoutWriteAll() throws Exception {
// /ids is gated WRITE_ALL because it powers the bulk-edit "Alle X
// editieren" fast path; no other consumer needs it.
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(List.of(id));
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0]").value(id.toString()));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_passesSenderIdParamToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID senderId = UUID.randomUUID();
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()))
.thenReturn(List.of());
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
.andExpect(status().isOk());
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_returns400_whenResultExceedsFilterCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(tooMany);
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
// ─── POST /api/documents/batch-metadata ──────────────────────────────────
@Test
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[]}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returns400_whenIdsExceedsCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
StringBuilder sb = new StringBuilder("{\"ids\":[");
for (int i = 0; i < 501; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(UUID.randomUUID()).append("\"");
}
sb.append("]}");
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content(sb.toString()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returnsSummaries_forExistingIds() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.batchMetadata(any())).thenReturn(List.of(
new org.raddatz.familienarchiv.dto.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file")));
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + id + "\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(id.toString()))
.andExpect(jsonPath("$[0].title").value("Brief"))
.andExpect(jsonPath("$[0].pdfUrl").value("/api/documents/" + id + "/file"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages() throws Exception {
// Nora C4 — DocumentController.sanitizeForLog defends against
// CWE-117 (log injection) by replacing CR/LF in any free-form string
// it interpolates. Same helper now sanitises BulkEditError.message
// before it round-trips to the frontend.
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID badId = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
"evil\r\nFAKE LOG ENTRY: admin logged in"));
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(badId.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\n"))))
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\r"))))
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.containsString("evil_")));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID okId = UUID.randomUUID();
UUID badId = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(okId), any(), any()))
.thenAnswer(inv -> Document.builder().id(okId).build());
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(okId.toString(), badId.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(1))
.andExpect(jsonPath("$.errors[0].id").value(badId.toString()))
.andExpect(jsonPath("$.errors[0].message").value(
org.hamcrest.Matchers.containsString("not found")));
}
}

View File

@@ -1,9 +1,6 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonNameAlias;
@@ -28,7 +25,6 @@ import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
@@ -57,13 +53,6 @@ class PersonControllerTest {
@Test
@WithMockUser
void getPersons_returns403_whenMissingReadAllPermission() throws Exception {
mockMvc.perform(get("/api/persons"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPersons_returns200_withEmptyList() throws Exception {
when(personService.findAll(null)).thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/persons"))
@@ -71,7 +60,7 @@ class PersonControllerTest {
}
@Test
@WithMockUser(authorities = "READ_ALL")
@WithMockUser
void getPersons_delegatesQueryParam_toService() throws Exception {
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
when(personService.findAll("Hans")).thenReturn(List.of(dto));
@@ -92,7 +81,6 @@ class PersonControllerTest {
public Integer getBirthYear() { return null; }
public Integer getDeathYear() { return null; }
public String getNotes() { return null; }
public boolean isFamilyMember() { return false; }
public long getDocumentCount() { return 0; }
};
}
@@ -107,13 +95,6 @@ class PersonControllerTest {
@Test
@WithMockUser
void getPerson_returns403_whenMissingReadAllPermission() throws Exception {
mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getPerson_returns200_whenFound() throws Exception {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
@@ -202,19 +183,19 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.content("{\"lastName\":\"Müller\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
.andExpect(status().isBadRequest());
}
@@ -223,7 +204,7 @@ class PersonControllerTest {
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\"Hans\"}"))
.andExpect(status().isBadRequest());
}
@@ -232,7 +213,7 @@ class PersonControllerTest {
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
.andExpect(status().isBadRequest());
}
@@ -244,53 +225,11 @@ class PersonControllerTest {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Hans"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns200_forInstitution_withoutFirstName() throws Exception {
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lastName").value("Verlag GmbH"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_trimsTitle_beforePersisting() throws Exception {
ArgumentCaptor<org.raddatz.familienarchiv.dto.PersonUpdateDTO> captor =
ArgumentCaptor.forClass(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class);
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personService.createPerson(captor.capture())).thenReturn(saved);
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk());
assertThat(captor.getValue().getTitle()).isEqualTo("Prof.");
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenPersonTypeIsSkip() throws Exception {
when(personService.createPerson(any())).thenThrow(
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"));
}
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
@Test
@@ -303,10 +242,10 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
.andExpect(status().isBadRequest());
}
@@ -315,7 +254,7 @@ class PersonControllerTest {
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\"Hans\"}"))
.andExpect(status().isBadRequest());
}
@@ -328,7 +267,7 @@ class PersonControllerTest {
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lastName").value("Müller"));
}
@@ -378,10 +317,11 @@ class PersonControllerTest {
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
// firstName valid, lastName blank → second || operand = true → 400
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
.andExpect(status().isBadRequest());
}
@@ -399,7 +339,7 @@ class PersonControllerTest {
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
"\"notes\":\"Some notes\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Maria"))
.andExpect(jsonPath("$.alias").value("Oma Maria"))
@@ -415,7 +355,7 @@ class PersonControllerTest {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
.andExpect(status().isBadRequest());
}
@@ -426,7 +366,7 @@ class PersonControllerTest {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
.andExpect(status().isBadRequest());
}
@@ -437,7 +377,7 @@ class PersonControllerTest {
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isForbidden());
}
@@ -446,7 +386,7 @@ class PersonControllerTest {
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isForbidden());
}

View File

@@ -183,36 +183,6 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String longName = "A".repeat(201);
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
+ "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
mockMvc.perform(post(URL_BASE)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
// ─── PUT /api/documents/{id}/transcription-blocks/{blockId} ─────────────
@Test
@@ -251,34 +221,6 @@ class TranscriptionBlockControllerTest {
.andExpect(jsonPath("$.label").value("Anrede"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns400_whenMentionedPersonDisplayNameExceeds200Chars() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String longName = "A".repeat(201);
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns400_whenMentionedPersonPersonIdIsNull() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
mockMvc.perform(put(URL_BLOCK)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updateBlock_returns404_whenBlockDoesNotExist() throws Exception {
@@ -318,13 +260,6 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete(URL_BLOCK))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void deleteBlock_returns204_whenAuthorised() throws Exception {
@@ -438,63 +373,4 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isOk())
.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());
}
}

View File

@@ -18,10 +18,8 @@ import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -106,55 +104,4 @@ class UserControllerTest {
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isBadRequest());
}
// ─── permission enforcement ───────────────────────────────────────────────
@Test
@WithMockUser(username = "reader@example.com")
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "reader@example.com")
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "reader@example.com")
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
// ─── unauthenticated access ───────────────────────────────────────────────
@Test
void createUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
.andExpect(status().isUnauthorized());
}
@Test
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void deleteUser_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -1,94 +0,0 @@
package org.raddatz.familienarchiv.dashboard;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
import org.raddatz.familienarchiv.audit.ContributorRow;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.jdbc.Sql;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class AuditLogQueryRepositoryContributorsTest {
static final UUID DOC_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
static final UUID USER_A = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000001");
static final UUID USER_B = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000002");
static final UUID USER_C = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000003");
static final UUID USER_D = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000004");
static final UUID USER_E = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-000000000005");
@Autowired AuditLogQueryRepository auditLogQueryRepository;
@Test
@Sql(statements = {
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#f00')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
})
void findRecentContributors_returns_contributor_with_initials_and_color() {
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
assertThat(rows.get(0).getActorInitials()).isEqualTo("AM");
assertThat(rows.get(0).getActorColor()).isEqualTo("#f00");
}
@Test
@Sql(statements = {
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000001', true, 'a@test.com', 'pw', 'Anna', 'Meier', '#aaa')",
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000002', true, 'b@test.com', 'pw', 'Ben', 'Wolf', '#bbb')",
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000003', true, 'c@test.com', 'pw', 'Clara', 'Zorn', '#ccc')",
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000004', true, 'd@test.com', 'pw', 'Dirk', 'Ott', '#ddd')",
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-000000000005', true, 'e@test.com', 'pw', 'Eva', 'Kern', '#eee')",
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '5 hours')",
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000002', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '4 hours')",
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000003', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '3 hours')",
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000004', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '2 hours')",
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at) VALUES ('BLOCK_REVIEWED', 'aaaaaaaa-aaaa-aaaa-aaaa-000000000005', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', now() - interval '1 hour')"
})
void findRecentContributors_limits_to_4_most_recent() {
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
assertThat(rows).hasSize(4);
// Most recent first: E, D, C, B (A is 5th, excluded)
assertThat(rows.get(0).getActorInitials()).isEqualTo("EK");
assertThat(rows.get(1).getActorInitials()).isEqualTo("DO");
assertThat(rows.get(2).getActorInitials()).isEqualTo("CZ");
assertThat(rows.get(3).getActorInitials()).isEqualTo("BW");
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')"
})
void findRecentContributors_returns_empty_when_no_audit_entries() {
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
assertThat(rows).isEmpty();
}
@Test
@Sql(statements = {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test', 'test.pdf', 'PLACEHOLDER')",
// Deleted user: ON DELETE SET NULL makes actor_id NULL — query excludes these rows
"INSERT INTO audit_log (kind, actor_id, document_id) VALUES ('TEXT_SAVED', NULL, 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
})
void findRecentContributors_excludes_entries_from_deleted_users() {
List<ContributorRow> rows = auditLogQueryRepository.findRecentContributorsForDocuments(List.of(DOC_ID));
assertThat(rows).isEmpty();
}
}

View File

@@ -49,16 +49,13 @@ class AuditLogQueryRepositoryIntegrationTest {
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Doc', 'test.pdf', 'PLACEHOLDER')",
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"pageNumber\":1}')"
})
void findRolledUpActivityFeed_returnsAnnotationEntry() {
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 10,
List.of("TEXT_SAVED","FILE_UPLOADED","ANNOTATION_CREATED","BLOCK_REVIEWED","COMMENT_ADDED","MENTION_CREATED"));
void findDedupedActivityFeed_returnsAnnotationEntry() {
List<ActivityFeedRow> rows = auditLogQueryRepository.findDedupedActivityFeed(USER_ID.toString(), 10);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("ANNOTATION_CREATED");
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_ID);
assertThat(rows.get(0).getHappenedAt()).isNotNull();
assertThat(rows.get(0).getCount()).isEqualTo(1);
assertThat(rows.get(0).getHappenedAtUntil()).isNull();
}
@Test

View File

@@ -1,411 +0,0 @@
package org.raddatz.familienarchiv.dashboard;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
@Transactional
class AuditLogQueryRepositoryRolledUpTest {
static final UUID USER_ID = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
static final UUID OTHER_USER_ID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
static final UUID DOC_ID = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
static final UUID OTHER_DOC_ID = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff");
static final List<String> ALL_ELIGIBLE_KINDS =
AuditKind.ROLLUP_ELIGIBLE.stream().map(Enum::name).toList();
private static final ObjectMapper MAPPER = new ObjectMapper();
@Autowired AuditLogQueryRepository auditLogQueryRepository;
@Autowired JdbcTemplate jdbcTemplate;
private NamedParameterJdbcTemplate named() {
return new NamedParameterJdbcTemplate(jdbcTemplate);
}
private void insertUserAndDocs() {
jdbcTemplate.update(
"INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')",
USER_ID, "rollup-" + USER_ID + "@test.com");
jdbcTemplate.update(
"INSERT INTO users (id, enabled, email, password) VALUES (?, true, ?, 'pw')",
OTHER_USER_ID, "rollup-" + OTHER_USER_ID + "@test.com");
jdbcTemplate.update(
"INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief A', 'a.pdf', 'PLACEHOLDER')",
DOC_ID);
jdbcTemplate.update(
"INSERT INTO documents (id, title, original_filename, status) VALUES (?, 'Brief B', 'b.pdf', 'PLACEHOLDER')",
OTHER_DOC_ID);
}
private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt) {
insertAuditEvent(actorId, docId, kind, happenedAt, Map.of());
}
private void insertAuditEvent(UUID actorId, UUID docId, String kind, Instant happenedAt, Map<String, String> payload) {
String payloadJson;
try {
payloadJson = payload.isEmpty() ? null : MAPPER.writeValueAsString(payload);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("kind", kind)
.addValue("actor", actorId)
.addValue("doc", docId)
.addValue("t", OffsetDateTime.ofInstant(happenedAt, java.time.ZoneOffset.UTC))
.addValue("payload", payloadJson, java.sql.Types.OTHER);
named().update(
"INSERT INTO audit_log (kind, actor_id, document_id, happened_at, payload) "
+ "VALUES (:kind, :actor, :doc, :t, :payload::jsonb)",
params);
}
private void insertReplyNotification(UUID recipientId, UUID docId, UUID commentId) {
jdbcTemplate.update(
"INSERT INTO notifications (recipient_id, type, document_id, reference_id) VALUES (?, 'REPLY', ?, ?)",
recipientId, docId, commentId);
}
@Test
void rolledUpFeed_combines_same_actor_same_doc_within_2h() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T09:00:00Z");
for (int i = 0; i < 20; i++) {
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L));
}
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
ActivityFeedRow row = rows.get(0);
assertThat(row.getKind()).isEqualTo("TEXT_SAVED");
assertThat(row.getDocumentId()).isEqualTo(DOC_ID);
assertThat(row.getCount()).isEqualTo(20);
assertThat(row.getHappenedAt()).isEqualTo(base);
assertThat(row.getHappenedAtUntil()).isEqualTo(base.plusSeconds(19 * 480L));
}
@Test
void rolledUpFeed_splits_at_2h_boundary() {
insertUserAndDocs();
Instant sessionOneStart = Instant.parse("2026-04-20T08:00:00Z");
Instant sessionOneLast = sessionOneStart.plusSeconds(600);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionOneStart);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionOneLast);
Instant sessionTwoStart = sessionOneLast.plusSeconds(2L * 60L * 60L + 60L);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart);
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", sessionTwoStart.plusSeconds(300));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(2);
assertThat(rows.get(0).getCount()).isEqualTo(2);
assertThat(rows.get(0).getHappenedAt()).isEqualTo(sessionTwoStart);
assertThat(rows.get(1).getCount()).isEqualTo(2);
assertThat(rows.get(1).getHappenedAt()).isEqualTo(sessionOneStart);
}
@Test
void rolledUpFeed_has_no_hard_cap_on_long_session() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T06:00:00Z");
for (int i = 0; i < 30; i++) {
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(i * 60L * 30L));
}
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getCount()).isEqualTo(30);
assertThat(rows.get(0).getHappenedAt()).isEqualTo(base);
assertThat(rows.get(0).getHappenedAtUntil()).isEqualTo(base.plusSeconds(29 * 60L * 30L));
}
@Test
void rolledUpFeed_never_rolls_up_COMMENT_ADDED_or_MENTION_CREATED() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T10:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base);
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(60));
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(120));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(3);
assertThat(rows).allSatisfy(r -> {
assertThat(r.getKind()).isEqualTo("COMMENT_ADDED");
assertThat(r.getCount()).isEqualTo(1);
assertThat(r.getHappenedAtUntil()).isNull();
});
}
@Test
void rolledUpFeed_excludes_non_eligible_kinds() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T12:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "STATUS_CHANGED", base);
insertAuditEvent(USER_ID, DOC_ID, "METADATA_UPDATED", base.plusSeconds(60));
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(120));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
}
@Test
void rolledUpFeed_exposes_count_and_happenedAtUntil_on_singletons_and_rollups() {
insertUserAndDocs();
Instant rollupStart = Instant.parse("2026-04-20T11:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", rollupStart);
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(300));
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", rollupStart.plusSeconds(900));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(2);
assertThat(rows).anySatisfy(r -> {
assertThat(r.getDocumentId()).isEqualTo(DOC_ID);
assertThat(r.getCount()).isEqualTo(2);
assertThat(r.getHappenedAt()).isEqualTo(rollupStart);
assertThat(r.getHappenedAtUntil()).isEqualTo(rollupStart.plusSeconds(300));
});
assertThat(rows).anySatisfy(r -> {
assertThat(r.getDocumentId()).isEqualTo(OTHER_DOC_ID);
assertThat(r.getCount()).isEqualTo(1);
assertThat(r.getHappenedAt()).isEqualTo(rollupStart.plusSeconds(900));
assertThat(r.getHappenedAtUntil()).isNull();
});
}
@Test
void youParticipated_is_true_when_user_has_reply_notification_for_comment() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
insertReplyNotification(USER_ID, DOC_ID, commentId);
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).anySatisfy(r ->
assertThat(r.isYouParticipated()).isTrue()
);
}
@Test
void youParticipated_is_false_for_comment_with_no_reply_notification() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouParticipated()).isFalse()
);
}
@Test
void youParticipated_is_false_when_comment_added_has_no_commentId_in_payload() {
insertUserAndDocs();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of());
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouParticipated()).isFalse()
);
}
@Test
void youParticipated_is_false_when_reply_notification_belongs_to_other_user() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
insertReplyNotification(OTHER_USER_ID, DOC_ID, commentId);
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouParticipated()).isFalse()
);
}
@Test
void youMentioned_is_true_when_mention_created_payload_matches_current_user() {
insertUserAndDocs();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "MENTION_CREATED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", USER_ID.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).anySatisfy(r ->
assertThat(r.isYouMentioned()).isTrue()
);
}
@Test
void rolledUpFeed_exposes_commentId_for_COMMENT_ADDED_events() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("commentId", commentId.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getCommentId()).isEqualTo(commentId);
}
@Test
void rolledUpFeed_exposes_commentId_for_MENTION_CREATED_events() {
insertUserAndDocs();
UUID commentId = UUID.randomUUID();
insertAuditEvent(OTHER_USER_ID, DOC_ID, "MENTION_CREATED",
Instant.parse("2026-04-20T10:00:00Z"),
Map.of("commentId", commentId.toString(), "mentionedUserId", USER_ID.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getCommentId()).isEqualTo(commentId);
}
@Test
void rolledUpFeed_commentId_is_null_for_non_comment_kinds() {
insertUserAndDocs();
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("blockId", "ccc", "pageNumber", "1"));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getCommentId()).isNull();
}
@Test
void youMentioned_is_false_when_mention_created_payload_targets_different_user() {
insertUserAndDocs();
insertAuditEvent(USER_ID, DOC_ID, "MENTION_CREATED",
Instant.parse("2026-04-20T10:00:00Z"), Map.of("mentionedUserId", OTHER_USER_ID.toString()));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(USER_ID.toString(), 40, ALL_ELIGIBLE_KINDS);
assertThat(rows).allSatisfy(r ->
assertThat(r.isYouMentioned()).isFalse()
);
}
// ─── kinds filter ─────────────────────────────────────────────────────────
@Test
void rolledUpFeed_with_single_kind_returns_only_that_kind() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T10:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
USER_ID.toString(), 40, List.of("FILE_UPLOADED"));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("FILE_UPLOADED");
}
@Test
void rolledUpFeed_with_multiple_kinds_returns_union() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T10:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(120));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
USER_ID.toString(), 40, List.of("TEXT_SAVED", "FILE_UPLOADED"));
assertThat(rows).hasSize(2);
assertThat(rows).extracting(ActivityFeedRow::getKind)
.containsExactlyInAnyOrder("TEXT_SAVED", "FILE_UPLOADED");
}
@Test
void rolledUpFeed_with_default_returns_all_six_eligible_kinds() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T10:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
insertAuditEvent(USER_ID, DOC_ID, "ANNOTATION_CREATED", base.plusSeconds(120));
insertAuditEvent(USER_ID, DOC_ID, "BLOCK_REVIEWED", base.plusSeconds(7300));
insertAuditEvent(USER_ID, DOC_ID, "COMMENT_ADDED", base.plusSeconds(7360));
insertAuditEvent(USER_ID, DOC_ID, "MENTION_CREATED", base.plusSeconds(7420));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
USER_ID.toString(), 40,
List.of("TEXT_SAVED", "FILE_UPLOADED", "ANNOTATION_CREATED",
"BLOCK_REVIEWED", "COMMENT_ADDED", "MENTION_CREATED"));
assertThat(rows).hasSize(6);
}
@Test
void rolledUpFeed_excludes_rows_not_in_filter_set() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T10:00:00Z");
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base);
insertAuditEvent(USER_ID, OTHER_DOC_ID, "FILE_UPLOADED", base.plusSeconds(60));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
USER_ID.toString(), 40, List.of("TEXT_SAVED"));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
}
@Test
void rolledUpFeed_rollup_still_works_when_kind_set_is_filtered_to_single_rollable_kind() {
insertUserAndDocs();
Instant base = Instant.parse("2026-04-20T09:00:00Z");
for (int i = 0; i < 10; i++) {
insertAuditEvent(USER_ID, DOC_ID, "TEXT_SAVED", base.plusSeconds(i * 480L));
}
insertAuditEvent(USER_ID, DOC_ID, "FILE_UPLOADED", base.plusSeconds(20));
List<ActivityFeedRow> rows = auditLogQueryRepository.findRolledUpActivityFeed(
USER_ID.toString(), 40, List.of("TEXT_SAVED"));
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getKind()).isEqualTo("TEXT_SAVED");
assertThat(rows.get(0).getCount()).isEqualTo(10);
}
}

View File

@@ -1,7 +1,6 @@
package org.raddatz.familienarchiv.dashboard;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.security.PermissionAspect;
@@ -16,12 +15,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -137,79 +134,10 @@ class DashboardControllerTest {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
when(dashboardService.getActivity(any(UUID.class), anyInt())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_clamps_limit_to_40() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity").param("limit", "9999"))
.andExpect(status().isOk());
verify(dashboardService).getActivity(any(UUID.class), org.mockito.ArgumentMatchers.eq(40), any());
}
// ─── GET /api/dashboard/activity — kinds param ───────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_parsesKinds_fromCsvQueryParam() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity")
.param("kinds", "FILE_UPLOADED", "TEXT_SAVED"))
.andExpect(status().isOk());
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
org.mockito.ArgumentMatchers.eq(Set.of(AuditKind.FILE_UPLOADED, AuditKind.TEXT_SAVED)));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_returns400_forUnknownKindValue() throws Exception {
mockMvc.perform(get("/api/dashboard/activity").param("kinds", "INVALID_KIND"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_defaults_to_rollupEligible_whenKindsAbsent() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity"))
.andExpect(status().isOk());
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
org.mockito.ArgumentMatchers.eq(AuditKind.ROLLUP_ELIGIBLE));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void activity_treats_single_valid_kind_as_filter() throws Exception {
UUID userId = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
when(dashboardService.getActivity(any(UUID.class), anyInt(), any())).thenReturn(List.of());
mockMvc.perform(get("/api/dashboard/activity").param("kinds", "COMMENT_ADDED"))
.andExpect(status().isOk());
verify(dashboardService).getActivity(any(UUID.class), anyInt(),
org.mockito.ArgumentMatchers.eq(Set.of(AuditKind.COMMENT_ADDED)));
}
}

View File

@@ -6,30 +6,21 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.PulseStatsRow;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.service.CommentService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.TranscriptionService;
import org.raddatz.familienarchiv.service.UserService;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -42,35 +33,9 @@ class DashboardServiceTest {
@Mock DocumentService documentService;
@Mock TranscriptionService transcriptionService;
@Mock UserService userService;
@Mock CommentService commentService;
@InjectMocks DashboardService dashboardService;
// ─── getResume wires thumbnailUrl from Document ───────────────────────────
@Test
void getResume_populatesThumbnailUrl_fromDocument() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.fromString("12345678-aaaa-bbbb-cccc-1234567890ab");
Document doc = Document.builder()
.id(docId).title("Brief").originalFilename("brief.pdf")
.thumbnailKey("thumbnails/" + docId + ".jpg")
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
.receivers(new HashSet<>())
.build();
when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId));
when(documentService.getDocumentById(docId)).thenReturn(doc);
when(transcriptionService.listBlocks(docId)).thenReturn(List.of());
DashboardResumeDTO result = dashboardService.getResume(userId);
assertThat(result).isNotNull();
assertThat(result.thumbnailUrl()).isEqualTo(doc.getThumbnailUrl());
assertThat(result.thumbnailUrl()).startsWith("/api/documents/" + docId + "/thumbnail?v=");
}
// ─── toActorDTO (via getResume collaborators) ─────────────────────────────
@Test
@@ -114,7 +79,7 @@ class DashboardServiceTest {
UUID docId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "ANNOTATION_CREATED");
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row, row));
when(auditLogQueryService.findActivityFeed(userId, 5)).thenReturn(List.of(row, row));
Document doc = Document.builder()
.id(docId).title("Familienbrief").originalFilename("f.pdf")
@@ -122,101 +87,14 @@ class DashboardServiceTest {
.build();
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(doc));
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5);
assertThat(items).hasSize(2);
assertThat(items.get(0).documentTitle()).isEqualTo("Familienbrief");
verify(documentService, never()).getDocumentById(docId);
}
// ─── getActivity comment/annotation enrichment ────────────────────────────
@Test
void getActivity_populatesCommentId_forCommentEvents() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
));
when(commentService.findAnnotationIdsByIds(List.of(commentId))).thenReturn(Map.of());
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
assertThat(items).hasSize(1);
assertThat(items.get(0).commentId()).isEqualTo(commentId);
}
@Test
void getActivity_populatesAnnotationId_viaCommentService() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID annotationId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "COMMENT_ADDED", commentId);
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
));
when(commentService.findAnnotationIdsByIds(List.of(commentId)))
.thenReturn(Map.of(commentId, annotationId));
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
assertThat(items).hasSize(1);
assertThat(items.get(0).annotationId()).isEqualTo(annotationId);
}
@Test
void getActivity_leavesBothNull_forNonCommentKinds() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
ActivityFeedRow row = mockFeedRow(docId, "TEXT_SAVED", null);
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row));
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(
Document.builder().id(docId).title("B").originalFilename("b.pdf").receivers(new HashSet<>()).build()
));
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
assertThat(items).hasSize(1);
assertThat(items.get(0).commentId()).isNull();
assertThat(items.get(0).annotationId()).isNull();
verify(commentService, never()).findAnnotationIdsByIds(anyList());
}
// ─── getPulse — always uses full ROLLUP_ELIGIBLE set ─────────────────────
@Test
void pulse_uses_all_rollup_eligible_kinds_never_calls_kinds_filtered_overload() {
UUID userId = UUID.randomUUID();
PulseStatsRow stats = new PulseStatsRow() {
public long getPages() { return 0; }
public long getAnnotated() { return 0; }
public long getTranscribed() { return 0; }
public long getUploaded() { return 0; }
public long getYourPages() { return 0; }
};
when(auditLogQueryService.getPulseStats(any(OffsetDateTime.class), any(UUID.class)))
.thenReturn(stats);
when(auditLogQueryService.findActivityFeed(userId, 50)).thenReturn(List.of());
dashboardService.getPulse(userId);
verify(auditLogQueryService).findActivityFeed(userId, 50);
verify(auditLogQueryService, never()).findActivityFeed(any(UUID.class), anyInt(), any(Set.class));
}
private ActivityFeedRow mockFeedRow(UUID docId, String kind) {
return mockFeedRow(docId, kind, null);
}
private ActivityFeedRow mockFeedRow(UUID docId, String kind, UUID commentId) {
return new ActivityFeedRow() {
public String getKind() { return kind; }
public UUID getActorId() { return null; }
@@ -226,10 +104,6 @@ class DashboardServiceTest {
public UUID getDocumentId() { return docId; }
public Instant getHappenedAt() { return Instant.now(); }
public boolean isYouMentioned() { return false; }
public boolean isYouParticipated() { return false; }
public int getCount() { return 1; }
public Instant getHappenedAtUntil() { return null; }
public UUID getCommentId() { return commentId; }
};
}
}

View File

@@ -2,103 +2,67 @@ package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.springframework.data.domain.PageRequest;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DocumentSearchResultTest {
private DocumentSearchItem item(UUID docId) {
Document doc = Document.builder()
.id(docId)
private Document doc(UUID id) {
return Document.builder()
.id(id)
.title("Test")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.build();
return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of());
}
@Test
void of_totalElements_equals_list_size_for_unpaged_shortcut() {
DocumentSearchResult result = DocumentSearchResult.of(
List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
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
void of_exposes_items_with_completion_and_contributors() {
void withMatchData_total_equals_list_size() {
UUID id = UUID.randomUUID();
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf")
.status(DocumentStatus.UPLOADED).build();
DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor));
List<Document> docs = List.of(doc(id));
Map<UUID, SearchMatchData> matchData = Map.of(id, SearchMatchData.empty());
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
DocumentSearchResult result = DocumentSearchResult.withMatchData(docs, matchData);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).completionPercentage()).isEqualTo(75);
assertThat(result.items().get(0).contributors()).containsExactly(actor);
assertThat(result.total()).isEqualTo(1L);
}
@Test
void items_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("items").getAnnotation(Schema.class);
void withMatchData_exposes_match_data_map() {
UUID id = UUID.randomUUID();
SearchMatchData data = new SearchMatchData("snippet", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
DocumentSearchResult result = DocumentSearchResult.withMatchData(List.of(doc(id)), Map.of(id, data));
assertThat(result.matchData()).containsKey(id);
assertThat(result.matchData().get(id).transcriptionSnippet()).isEqualTo("snippet");
}
@Test
void of_factory_returns_empty_match_data() {
UUID id = UUID.randomUUID();
DocumentSearchResult result = DocumentSearchResult.of(List.of(doc(id)));
assertThat(result.matchData()).isEmpty();
assertThat(result.total()).isEqualTo(1L);
}
@Test
void documents_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("documents").getAnnotation(Schema.class);
assertThat(schema).isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
@Test
void totalElements_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("totalElements").getAnnotation(Schema.class);
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
assertThat(schema).isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
@Test
void paging_components_are_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
for (String name : List.of("pageNumber", "pageSize", "totalPages")) {
Schema schema = DocumentSearchResult.class.getDeclaredField(name).getAnnotation(Schema.class);
assertThat(schema).as(name + " must have @Schema").isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
}
}

View File

@@ -1,85 +0,0 @@
package org.raddatz.familienarchiv.model;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DocumentTest {
@Test
void getThumbnailUrl_returnsNull_whenThumbnailKeyNull() {
Document doc = Document.builder()
.id(UUID.randomUUID())
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey(null)
.build();
assertThat(doc.getThumbnailUrl()).isNull();
}
@Test
void getThumbnailUrl_omitsCacheBuster_whenThumbnailKeyPresentButGeneratedAtNull() {
UUID id = UUID.fromString("11111111-2222-3333-4444-555555555555");
Document doc = Document.builder()
.id(id)
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey("thumbnails/" + id + ".jpg")
.thumbnailGeneratedAt(null)
.build();
assertThat(doc.getThumbnailUrl())
.isEqualTo("/api/documents/" + id + "/thumbnail");
}
@Test
void getThumbnailUrl_includesCacheBuster_whenBothKeyAndGeneratedAtPresent() {
UUID id = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
LocalDateTime generatedAt = LocalDateTime.of(2026, 4, 23, 14, 30, 45);
Document doc = Document.builder()
.id(id)
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey("thumbnails/" + id + ".jpg")
.thumbnailGeneratedAt(generatedAt)
.build();
// frontend equivalent: `?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}`
// where thumbnailGeneratedAt is the ISO-8601 string Jackson serialises.
// LocalDateTime.toString() produces "2026-04-23T14:30:45"; encodeURIComponent
// turns ":" into "%3A" but leaves "T" and digits alone.
String expected = "/api/documents/" + id + "/thumbnail?v=2026-04-23T14%3A30%3A45";
assertThat(doc.getThumbnailUrl()).isEqualTo(expected);
}
@Test
void thumbnailUrl_isSerialisedToJson_soFrontendReceivesIt() throws Exception {
UUID id = UUID.fromString("99999999-aaaa-bbbb-cccc-111122223333");
Document doc = Document.builder()
.id(id)
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey("thumbnails/" + id + ".jpg")
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
.build();
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
String json = mapper.writeValueAsString(doc);
// Locks the wire contract, not just the Java API: every Document JSON must carry
// `thumbnailUrl`. Protects against silent breakage if the getter gets renamed,
// hidden behind @JsonIgnore, or visibility-reduced — any of which would leave the
// frontend rendering the fallback icon on every surface.
assertThat(json).contains("\"thumbnailUrl\":\"" + doc.getThumbnailUrl() + "\"");
}
}

View File

@@ -1,160 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(RelationshipController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class RelationshipControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean RelationshipService relationshipService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final UUID PERSON_ID = UUID.randomUUID();
private static final UUID OTHER_ID = UUID.randomUUID();
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getRelationshipBetween_returns404_with_RELATIONSHIP_NOT_FOUND_code_when_no_path() throws Exception {
when(relationshipService.getRelationshipBetween(any(), any())).thenReturn(Optional.empty());
mockMvc.perform(get("/api/persons/{aId}/relationship-to/{bId}", PERSON_ID, OTHER_ID))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(ErrorCode.RELATIONSHIP_NOT_FOUND.name()));
}
@Test
void getRelationships_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/persons/{id}/relationships", PERSON_ID))
.andExpect(status().isUnauthorized());
}
@Test
void getNetwork_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/network"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void addRelationship_returns403_for_user_with_READ_ALL_only() throws Exception {
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void deleteRelationship_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void patchFamilyMember_returns403_for_READ_ALL_only_user() throws Exception {
mockMvc.perform(patch("/api/persons/{id}/family-member", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"familyMember\":true}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception {
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, true);
RelationshipDTO edge = new RelationshipDTO(
UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", 1900, 1980,
"Bob Müller", 1930, null,
RelationType.PARENT_OF, null, null, null);
when(relationshipService.getFamilyNetwork())
.thenReturn(new NetworkDTO(List.of(node), List.of(edge)));
mockMvc.perform(get("/api/network"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.nodes[0].displayName").value("Alice Müller"))
.andExpect(jsonPath("$.edges[0].relationType").value("PARENT_OF"));
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception {
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, true);
InferredRelationshipWithPersonDTO inferred =
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
when(relationshipService.getInferredRelationships(PERSON_ID))
.thenReturn(List.of(inferred));
mockMvc.perform(get("/api/persons/{id}/inferred-relationships", PERSON_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].label").value("Großvater"))
.andExpect(jsonPath("$[0].hops").value(2));
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void addRelationship_returns400_when_relationType_is_unknown_value() throws Exception {
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"NOT_A_REAL_TYPE\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void addRelationship_returns201_with_RelationshipDTO_for_WRITE_ALL_user() throws Exception {
RelationshipDTO created = new RelationshipDTO(
UUID.randomUUID(), PERSON_ID, OTHER_ID,
"Alice Müller", null, null,
"Bob Müller", null, null,
RelationType.PARENT_OF, null, null, null);
when(relationshipService.addRelationship(any(), any())).thenReturn(created);
mockMvc.perform(post("/api/persons/{id}/relationships", PERSON_ID)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"relatedPersonId\":\"" + OTHER_ID + "\",\"relationType\":\"PARENT_OF\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.relationType").value("PARENT_OF"));
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void deleteRelationship_returns204_for_WRITE_ALL_user() throws Exception {
UUID relId = UUID.randomUUID();
doNothing().when(relationshipService).deleteRelationship(any(), any());
mockMvc.perform(delete("/api/persons/{id}/relationships/{relId}", PERSON_ID, relId))
.andExpect(status().isNoContent());
}
}

View File

@@ -1,353 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
import org.raddatz.familienarchiv.service.PersonService;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static java.util.Collections.emptyList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
import static org.raddatz.familienarchiv.relationship.RelationToken.*;
import static org.raddatz.familienarchiv.relationship.RelationType.*;
/**
* Felix Brandt — TDD red phase for RelationshipInferenceService.
* <p>
* 18 unit tests, one per LABEL_MAP entry plus one no-path case. Each setup wires
* a small graph through the mocked repository and asserts the exact abstract
* token sequence emitted by BFS — except {@code distant_label_for_long_chain}
* which asserts the fallback label, and {@code returns_empty_when_no_path}
* which asserts no result.
*/
@ExtendWith(MockitoExtension.class)
class RelationshipInferenceServiceTest {
@Mock PersonRelationshipRepository relationshipRepository;
@Mock PersonService personService;
@InjectMocks RelationshipInferenceService service;
// --- 1: parent ---
@Test
void parent_path_emits_UP() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
assertThat(service.findShortestPath(child.getId(), parent.getId()))
.hasValue(List.of(UP));
}
// --- 2: child ---
@Test
void child_path_emits_DOWN() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
assertThat(service.findShortestPath(parent.getId(), child.getId()))
.hasValue(List.of(DOWN));
}
// --- 3: spouse ---
@Test
void spouse_path_emits_SPOUSE() {
Person a = person();
Person b = person();
givenEdges(spouseOf(a, b));
assertThat(service.findShortestPath(a.getId(), b.getId()))
.hasValue(List.of(SPOUSE));
}
// --- 4: sibling ---
@Test
void sibling_path_emits_SIBLING() {
Person a = person();
Person b = person();
givenEdges(siblingOf(a, b));
assertThat(service.findShortestPath(a.getId(), b.getId()))
.hasValue(List.of(SIBLING));
}
// --- 5: grandparent (UP, UP) ---
@Test
void grandparent_path_emits_UP_UP() {
Person grandparent = person();
Person parent = person();
Person grandchild = person();
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
assertThat(service.findShortestPath(grandchild.getId(), grandparent.getId()))
.hasValue(List.of(UP, UP));
}
// --- 6: grandchild (DOWN, DOWN) ---
@Test
void grandchild_path_emits_DOWN_DOWN() {
Person grandparent = person();
Person parent = person();
Person grandchild = person();
givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
assertThat(service.findShortestPath(grandparent.getId(), grandchild.getId()))
.hasValue(List.of(DOWN, DOWN));
}
// --- 7: great-grandparent (UP, UP, UP) ---
@Test
void great_grandparent_path_emits_UP_UP_UP() {
Person g = person();
Person p = person();
Person c = person();
Person gc = person();
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
assertThat(service.findShortestPath(gc.getId(), g.getId()))
.hasValue(List.of(UP, UP, UP));
}
// --- 8: great-grandchild (DOWN, DOWN, DOWN) ---
@Test
void great_grandchild_path_emits_DOWN_DOWN_DOWN() {
Person g = person();
Person p = person();
Person c = person();
Person gc = person();
givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
assertThat(service.findShortestPath(g.getId(), gc.getId()))
.hasValue(List.of(DOWN, DOWN, DOWN));
}
// --- 9: uncle/aunt (UP, SIBLING) ---
@Test
void uncle_aunt_path_emits_UP_SIBLING() {
Person grandparent = person();
Person parent = person();
Person uncle = person();
Person me = person();
// grandparent has two children: parent and uncle. me is parent's child.
givenEdges(
parentOf(grandparent, parent),
parentOf(grandparent, uncle),
parentOf(parent, me));
assertThat(service.findShortestPath(me.getId(), uncle.getId()))
.hasValue(List.of(UP, SIBLING));
}
// --- 10: niece/nephew (SIBLING, DOWN) ---
@Test
void niece_nephew_path_emits_SIBLING_DOWN() {
Person grandparent = person();
Person uncle = person();
Person sibling = person();
Person niece = person();
// grandparent has uncle + sibling; sibling has niece.
givenEdges(
parentOf(grandparent, uncle),
parentOf(grandparent, sibling),
parentOf(sibling, niece));
assertThat(service.findShortestPath(uncle.getId(), niece.getId()))
.hasValue(List.of(SIBLING, DOWN));
}
// --- 11: great uncle/aunt (UP, UP, SIBLING) ---
@Test
void great_uncle_aunt_path_emits_UP_UP_SIBLING() {
Person ggp = person();
Person grandparent = person();
Person greatUncle = person();
Person parent = person();
Person me = person();
givenEdges(
parentOf(ggp, grandparent),
parentOf(ggp, greatUncle),
parentOf(grandparent, parent),
parentOf(parent, me));
assertThat(service.findShortestPath(me.getId(), greatUncle.getId()))
.hasValue(List.of(UP, UP, SIBLING));
}
// --- 12: great niece/nephew (SIBLING, DOWN, DOWN) ---
@Test
void great_niece_nephew_path_emits_SIBLING_DOWN_DOWN() {
Person grandparent = person();
Person sibling = person();
Person greatUncle = person();
Person niece = person();
Person greatNiece = person();
givenEdges(
parentOf(grandparent, sibling),
parentOf(grandparent, greatUncle),
parentOf(sibling, niece),
parentOf(niece, greatNiece));
assertThat(service.findShortestPath(greatUncle.getId(), greatNiece.getId()))
.hasValue(List.of(SIBLING, DOWN, DOWN));
}
// --- 13: parent-in-law (SPOUSE, UP) ---
@Test
void inlaw_parent_path_emits_SPOUSE_UP() {
Person inlaw = person();
Person spouse = person();
Person me = person();
givenEdges(
parentOf(inlaw, spouse),
spouseOf(me, spouse));
assertThat(service.findShortestPath(me.getId(), inlaw.getId()))
.hasValue(List.of(SPOUSE, UP));
}
// --- 14: child-in-law (DOWN, SPOUSE) ---
@Test
void inlaw_child_path_emits_DOWN_SPOUSE() {
Person me = person();
Person child = person();
Person inlawChild = person();
givenEdges(
parentOf(me, child),
spouseOf(child, inlawChild));
assertThat(service.findShortestPath(me.getId(), inlawChild.getId()))
.hasValue(List.of(DOWN, SPOUSE));
}
// --- 15: sibling-in-law via my spouse's sibling (SPOUSE, SIBLING) ---
@Test
void sibling_inlaw_via_spouse_emits_SPOUSE_SIBLING() {
Person me = person();
Person spouse = person();
Person spouseSibling = person();
givenEdges(
spouseOf(me, spouse),
siblingOf(spouse, spouseSibling));
assertThat(service.findShortestPath(me.getId(), spouseSibling.getId()))
.hasValue(List.of(SPOUSE, SIBLING));
}
// --- 16: cousin (UP, SIBLING, DOWN) ---
@Test
void cousin_1_path_emits_UP_SIBLING_DOWN() {
Person ggp = person();
Person parentMine = person();
Person uncle = person();
Person me = person();
Person cousin = person();
givenEdges(
parentOf(ggp, parentMine),
parentOf(ggp, uncle),
parentOf(parentMine, me),
parentOf(uncle, cousin));
assertThat(service.findShortestPath(me.getId(), cousin.getId()))
.hasValue(List.of(UP, SIBLING, DOWN));
}
// --- 17: distant (label fallback for long chains) ---
@Test
void distant_label_for_long_chain() {
// Seven-generation ancestor: chain of seven PARENT_OF edges.
Person a0 = person();
Person a1 = person();
Person a2 = person();
Person a3 = person();
Person a4 = person();
Person a5 = person();
Person a6 = person();
Person a7 = person();
givenEdges(
parentOf(a0, a1),
parentOf(a1, a2),
parentOf(a2, a3),
parentOf(a3, a4),
parentOf(a4, a5),
parentOf(a5, a6),
parentOf(a6, a7));
Optional<InferredRelationshipDTO> inferred = service.infer(a7.getId(), a0.getId());
assertThat(inferred).hasValueSatisfying(r -> {
assertThat(r.hops()).isEqualTo(7);
assertThat(r.labelFromA()).isEqualTo("distant");
assertThat(r.labelFromB()).isEqualTo("distant");
});
}
// --- 18: no path ---
@Test
void returns_empty_when_no_path() {
Person a = person();
Person b = person();
// No edges between them.
givenEdges(/* none */);
assertThat(service.findShortestPath(a.getId(), b.getId())).isEmpty();
assertThat(service.infer(a.getId(), b.getId())).isEmpty();
}
// --- 19: findAllFor delegates person resolution to PersonService ---
@Test
void findAllFor_resolves_persons_via_PersonService() {
Person parent = person();
Person child = person();
givenEdges(parentOf(parent, child));
when(personService.getAllById(anyList())).thenReturn(List.of(child));
List<InferredRelationshipWithPersonDTO> results = service.findAllFor(parent.getId());
assertThat(results).hasSize(1);
assertThat(results.get(0).person().displayName()).isEqualTo(child.getDisplayName());
}
// --- helpers ---
private void givenEdges(PersonRelationship... edges) {
when(relationshipRepository.findAllByRelationTypeIn(anyCollection()))
.thenReturn(edges.length == 0 ? emptyList() : List.of(edges));
}
private static Person person() {
return Person.builder().id(UUID.randomUUID()).lastName("X").familyMember(true).build();
}
private static PersonRelationship parentOf(Person parent, Person child) {
return edge(parent, child, PARENT_OF);
}
private static PersonRelationship spouseOf(Person a, Person b) {
return edge(a, b, SPOUSE_OF);
}
private static PersonRelationship siblingOf(Person a, Person b) {
return edge(a, b, SIBLING_OF);
}
private static PersonRelationship edge(Person a, Person b, RelationType type) {
return PersonRelationship.builder()
.id(UUID.randomUUID())
.person(a)
.relatedPerson(b)
.relationType(type)
.createdAt(Instant.now())
.build();
}
}

View File

@@ -1,181 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import org.raddatz.familienarchiv.relationship.dto.RelationshipDTO;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Sara blocker 1 — service+DB integration over the family-network constraints.
* Hits the real Postgres so unique_rel, ON DELETE CASCADE, and the partial
* sibling index actually fire.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({
PostgresContainerConfig.class,
FlywayConfig.class,
RelationshipService.class,
RelationshipInferenceService.class,
PersonService.class
})
class RelationshipServiceIntegrationTest {
@Autowired RelationshipService relationshipService;
@Autowired PersonRepository personRepository;
@Autowired PersonRelationshipRepository relationshipRepository;
// PersonService → PersonNameAliasRepository; @DataJpaTest auto-loads it.
@Autowired PersonNameAliasRepository aliasRepository;
@Autowired EntityManager entityManager;
Person alice;
Person bob;
Person charlie;
@BeforeEach
void seed() {
relationshipRepository.deleteAll();
aliasRepository.deleteAll();
personRepository.deleteAll();
alice = personRepository.save(Person.builder().firstName("Alice").lastName("Müller").familyMember(true).build());
bob = personRepository.save(Person.builder().firstName("Bob").lastName("Müller").familyMember(true).build());
charlie = personRepository.save(Person.builder().firstName("Charlie").lastName("Schmidt").familyMember(false).build());
}
@Test
void addRelationship_stores_and_is_readable() {
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, null);
RelationshipDTO created = relationshipService.addRelationship(alice.getId(), dto);
assertThat(created.id()).isNotNull();
assertThat(created.personId()).isEqualTo(alice.getId());
assertThat(created.relatedPersonId()).isEqualTo(bob.getId());
List<RelationshipDTO> rels = relationshipService.getRelationships(alice.getId());
assertThat(rels).hasSize(1);
assertThat(rels.get(0).relationType()).isEqualTo(RelationType.PARENT_OF);
}
@Test
void addRelationship_throws_409_when_duplicate() {
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
relationshipService.addRelationship(alice.getId(), dto);
assertThatThrownBy(() -> relationshipService.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void addRelationship_throws_409_when_circular_parent() {
// alice PARENT_OF bob; now try bob PARENT_OF alice → must be rejected.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
}
@Test
void deleteRelationship_throws_403_when_rel_belongs_to_different_person() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
// Charlie is unrelated to this row.
assertThatThrownBy(() -> relationshipService.deleteRelationship(charlie.getId(), created.id()))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
// The row is still there.
assertThat(relationshipRepository.findById(created.id())).isPresent();
}
@Test
void addRelationship_throws_409_when_reverse_SPOUSE_OF_pair_already_exists() {
// V55 enforces symmetric uniqueness for SPOUSE_OF. Inserting (alice, bob, SPOUSE_OF)
// and then (bob, alice, SPOUSE_OF) must be rejected, just like reverse SIBLING_OF.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
var reverse = new CreateRelationshipRequest(alice.getId(), RelationType.SPOUSE_OF, null, null, null);
assertThatThrownBy(() -> relationshipService.addRelationship(bob.getId(), reverse))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void deleteRelationship_succeeds_for_symmetric_type_from_either_side() {
// alice SPOUSE_OF bob. Bob deletes from his side.
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.SPOUSE_OF, null, null, null));
relationshipService.deleteRelationship(bob.getId(), created.id());
assertThat(relationshipRepository.findById(created.id())).isEmpty();
}
@Test
void setFamilyMember_true_makes_person_appear_in_network() {
// charlie starts with familyMember = false. Add a PARENT_OF edge alice→charlie
// so the edge exists, then flip charlie's flag and verify he appears in nodes.
relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
NetworkDTO before = relationshipService.getFamilyNetwork();
assertThat(before.nodes()).extracting("id").doesNotContain(charlie.getId());
relationshipService.setFamilyMember(charlie.getId(), true);
NetworkDTO after = relationshipService.getFamilyNetwork();
assertThat(after.nodes()).extracting("id").contains(charlie.getId());
assertThat(after.edges())
.anyMatch(e -> e.personId().equals(alice.getId()) && e.relatedPersonId().equals(charlie.getId()));
}
@Test
void delete_person_cascades_to_relationships() {
RelationshipDTO created = relationshipService.addRelationship(alice.getId(),
new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null));
UUID relId = created.id();
assertThat(relationshipRepository.findById(relId)).isPresent();
// Detach managed entities so deleteById's cascade isn't fought by the
// persistence context (the rel row still references bob in memory).
entityManager.flush();
entityManager.clear();
// Delete bob (the relatedPerson) — DB CASCADE must remove the row.
personRepository.deleteById(bob.getId());
personRepository.flush();
assertThat(relationshipRepository.findById(relId)).isEmpty();
}
}

View File

@@ -1,209 +0,0 @@
package org.raddatz.familienarchiv.relationship;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.relationship.dto.CreateRelationshipRequest;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.dao.DataIntegrityViolationException;
import org.raddatz.familienarchiv.relationship.dto.NetworkDTO;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Felix Brandt — TDD red for RelationshipService domain rules.
*
* <p>Required by the plan (Nora blockers 1 + 2):
* <ul>
* <li>{@code deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person}</li>
* <li>{@code addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists}</li>
* </ul>
* Plus: duplicate constraint, self-relationship, year-range, happy-path persistence,
* and ownership permitted from either side.
*/
@ExtendWith(MockitoExtension.class)
class RelationshipServiceTest {
@Mock PersonRelationshipRepository relationshipRepository;
@Mock PersonService personService;
@Mock RelationshipInferenceService inferenceService;
@InjectMocks RelationshipService service;
Person alice;
Person bob;
Person charlie;
@BeforeEach
void seed() {
alice = person("Alice");
bob = person("Bob");
charlie = person("Charlie");
}
// --- Nora blocker 1 ---
@Test
void deleteRelationship_throws_FORBIDDEN_when_rel_belongs_to_different_person() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
assertThatThrownBy(() -> service.deleteRelationship(charlie.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.FORBIDDEN);
verify(relationshipRepository, never()).delete(any());
}
// --- Nora blocker 2 ---
@Test
void addRelationship_throws_CIRCULAR_when_reverse_PARENT_OF_exists() {
// alice PARENT_OF bob already exists. Now we try to add bob PARENT_OF alice.
when(personService.getById(bob.getId())).thenReturn(bob);
when(personService.getById(alice.getId())).thenReturn(alice);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
alice.getId(), bob.getId(), RelationType.PARENT_OF)).thenReturn(true);
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> service.addRelationship(bob.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.CIRCULAR_RELATIONSHIP);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_throws_DUPLICATE_when_db_constraint_violated() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("unique_rel"));
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.DUPLICATE_RELATIONSHIP);
}
@Test
void addRelationship_throws_BAD_REQUEST_when_self_relationship() {
var dto = new CreateRelationshipRequest(alice.getId(), RelationType.FRIEND, null, null, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_throws_BAD_REQUEST_when_to_year_before_from_year() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, 1950, 1940, null);
assertThatThrownBy(() -> service.addRelationship(alice.getId(), dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(relationshipRepository, never()).saveAndFlush(any());
}
@Test
void addRelationship_persists_with_storage_truth() {
when(personService.getById(alice.getId())).thenReturn(alice);
when(personService.getById(bob.getId())).thenReturn(bob);
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
PersonRelationship r = inv.getArgument(0);
r.setId(UUID.randomUUID());
r.setCreatedAt(Instant.now());
return r;
});
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, 1900, null, "first born");
var result = service.addRelationship(alice.getId(), dto);
assertThat(result.personId()).isEqualTo(alice.getId());
assertThat(result.relatedPersonId()).isEqualTo(bob.getId());
assertThat(result.relationType()).isEqualTo(RelationType.PARENT_OF);
assertThat(result.fromYear()).isEqualTo(1900);
assertThat(result.notes()).isEqualTo("first born");
}
@Test
void deleteRelationship_succeeds_when_viewpoint_is_object() {
UUID relId = UUID.randomUUID();
PersonRelationship rel = parentOf(alice, bob, relId);
when(relationshipRepository.findById(relId)).thenReturn(Optional.of(rel));
// Bob is the storage related_person; deleting from his viewpoint should work.
service.deleteRelationship(bob.getId(), relId);
verify(relationshipRepository).delete(rel);
}
@Test
void deleteRelationship_throws_NOT_FOUND_when_relId_unknown() {
UUID relId = UUID.randomUUID();
when(relationshipRepository.findById(relId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.deleteRelationship(alice.getId(), relId))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.RELATIONSHIP_NOT_FOUND);
}
@Test
void getFamilyNetwork_excludes_edges_where_one_endpoint_is_not_a_family_member() {
// alice and bob are family members; charlie is NOT (not in findAllFamilyMembers result).
// Two edges exist: alice→bob (both family) and alice→charlie (one non-family).
// Only the alice→bob edge must appear in the returned NetworkDTO.
UUID aliceBobRelId = UUID.randomUUID();
UUID aliceCharlieRelId = UUID.randomUUID();
PersonRelationship aliceBob = parentOf(alice, bob, aliceBobRelId);
PersonRelationship aliceCharlie = parentOf(alice, charlie, aliceCharlieRelId);
when(personService.findAllFamilyMembers()).thenReturn(List.of(alice, bob));
when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of(aliceBob, aliceCharlie));
NetworkDTO result = service.getFamilyNetwork();
assertThat(result.nodes()).hasSize(2);
assertThat(result.edges()).hasSize(1);
assertThat(result.edges().get(0).personId()).isEqualTo(alice.getId());
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
}
// --- helpers ---
private static Person person(String name) {
return Person.builder().id(UUID.randomUUID()).lastName(name).familyMember(true).build();
}
private static PersonRelationship parentOf(Person parent, Person child, UUID id) {
return PersonRelationship.builder()
.id(id)
.person(parent)
.relatedPerson(child)
.relationType(RelationType.PARENT_OF)
.createdAt(Instant.now())
.build();
}
}

Some files were not shown because too many files have changed in this diff Show More