Compare commits
1 Commits
feat/issue
...
5b15991cf3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b15991cf3 |
12
CLAUDE.md
12
CLAUDE.md
@@ -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">
|
<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
|
```svelte
|
||||||
<script lang="ts">
|
<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">
|
||||||
import BackButton from '$lib/components/BackButton.svelte';
|
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" .../>
|
||||||
</script>
|
Zurück zur Übersicht
|
||||||
|
</a>
|
||||||
<BackButton />
|
|
||||||
```
|
```
|
||||||
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"):
|
Subtle action link (e.g. "new document/person"):
|
||||||
```svelte
|
```svelte
|
||||||
|
|||||||
@@ -103,11 +103,6 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.awaitility</groupId>
|
|
||||||
<artifactId>awaitility</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Excel Bearbeitung (Apache POI) -->
|
<!-- Excel Bearbeitung (Apache POI) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -164,19 +159,12 @@
|
|||||||
<version>3.0.2</version>
|
<version>3.0.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- PDF rendering for training data export and thumbnail generation -->
|
<!-- PDF rendering for training data export -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.apache.pdfbox</groupId>
|
<groupId>org.apache.pdfbox</groupId>
|
||||||
<artifactId>pdfbox</artifactId>
|
<artifactId>pdfbox</artifactId>
|
||||||
<version>3.0.4</version>
|
<version>3.0.4</version>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.annotation.Nullable;
|
|
||||||
|
|
||||||
public record ActivityActorDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String initials,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String color,
|
|
||||||
@Nullable String name
|
|
||||||
) {}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface ActivityFeedRow {
|
|
||||||
String getKind();
|
|
||||||
UUID getActorId();
|
|
||||||
String getActorInitials();
|
|
||||||
String getActorColor();
|
|
||||||
String getActorName();
|
|
||||||
UUID getDocumentId();
|
|
||||||
Instant getHappenedAt();
|
|
||||||
boolean isYouMentioned();
|
|
||||||
boolean isYouParticipated();
|
|
||||||
int getCount();
|
|
||||||
Instant getHappenedAtUntil();
|
|
||||||
/** Present only for COMMENT_ADDED and MENTION_CREATED — null otherwise. */
|
|
||||||
UUID getCommentId();
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
package org.raddatz.familienarchiv.audit;
|
||||||
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
public enum AuditKind {
|
public enum AuditKind {
|
||||||
|
|
||||||
/** Payload: none */
|
/** Payload: none */
|
||||||
@@ -27,18 +25,4 @@ public enum AuditKind {
|
|||||||
|
|
||||||
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
/** Payload: {@code {"commentId": "uuid", "mentionedUserId": "uuid"}} */
|
||||||
MENTION_CREATED,
|
MENTION_CREATED,
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
|
||||||
USER_CREATED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "email": "addr"}} */
|
|
||||||
USER_DELETED,
|
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "email": "addr", "addedGroups": ["Admin"], "removedGroups": []}} */
|
|
||||||
GROUP_MEMBERSHIP_CHANGED;
|
|
||||||
|
|
||||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
|
||||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
|
||||||
|
|
||||||
@Query(value = """
|
|
||||||
SELECT a.document_id
|
|
||||||
FROM audit_log a
|
|
||||||
WHERE a.kind IN ('TEXT_SAVED', 'ANNOTATION_CREATED')
|
|
||||||
AND a.actor_id = :userId
|
|
||||||
AND a.document_id IS NOT NULL
|
|
||||||
ORDER BY a.happened_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""", nativeQuery = true)
|
|
||||||
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,
|
|
||||||
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
|
|
||||||
LIMIT :limit
|
|
||||||
""", nativeQuery = true)
|
|
||||||
List<ActivityFeedRow> findRolledUpActivityFeed(
|
|
||||||
@Param("currentUserId") String currentUserId,
|
|
||||||
@Param("limit") int limit,
|
|
||||||
@Param("kinds") Collection<String> kinds);
|
|
||||||
|
|
||||||
@Query(value = """
|
|
||||||
SELECT
|
|
||||||
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber'))) AS pages,
|
|
||||||
COUNT(*) FILTER (WHERE a.kind = 'ANNOTATION_CREATED') AS annotated,
|
|
||||||
COUNT(DISTINCT a.payload->>'blockId') FILTER (WHERE a.kind = 'TEXT_SAVED') AS transcribed,
|
|
||||||
COUNT(DISTINCT a.document_id) FILTER (WHERE a.kind = 'FILE_UPLOADED') AS uploaded,
|
|
||||||
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber')))
|
|
||||||
FILTER (WHERE (a.kind = 'ANNOTATION_CREATED' OR a.kind = 'TEXT_SAVED')
|
|
||||||
AND a.actor_id::text = :userId) AS yourPages
|
|
||||||
FROM audit_log a
|
|
||||||
WHERE a.happened_at >= :weekStart
|
|
||||||
AND a.kind IN ('ANNOTATION_CREATED','TEXT_SAVED','FILE_UPLOADED')
|
|
||||||
""", nativeQuery = true)
|
|
||||||
PulseStatsRow getPulseStats(
|
|
||||||
@Param("weekStart") OffsetDateTime weekStart,
|
|
||||||
@Param("userId") String userId);
|
|
||||||
|
|
||||||
@Query(value = """
|
|
||||||
SELECT DISTINCT ON (a.document_id)
|
|
||||||
a.document_id AS documentId,
|
|
||||||
a.actor_id AS actorId
|
|
||||||
FROM audit_log a
|
|
||||||
WHERE a.kind = :kind
|
|
||||||
AND a.document_id IN :documentIds
|
|
||||||
AND a.actor_id IS NOT NULL
|
|
||||||
ORDER BY a.document_id, a.happened_at DESC
|
|
||||||
""", nativeQuery = true)
|
|
||||||
List<Object[]> findMostRecentActorPerDocument(
|
|
||||||
@Param("documentIds") List<UUID> documentIds,
|
|
||||||
@Param("kind") String kind);
|
|
||||||
|
|
||||||
@Query(value = """
|
|
||||||
SELECT
|
|
||||||
a.document_id AS documentId,
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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 {
|
|
||||||
|
|
||||||
private final AuditLogQueryRepository queryRepository;
|
|
||||||
|
|
||||||
public Optional<UUID> findMostRecentDocumentForUser(UUID userId) {
|
|
||||||
return queryRepository.findMostRecentDocumentIdByActor(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public PulseStatsRow getPulseStats(OffsetDateTime weekStart, UUID userId) {
|
|
||||||
return queryRepository.getPulseStats(weekStart, userId.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<UUID, UUID> findMostRecentActorPerDocument(List<UUID> documentIds, String kind) {
|
|
||||||
if (documentIds.isEmpty()) return Map.of();
|
|
||||||
List<Object[]> rows = queryRepository.findMostRecentActorPerDocument(documentIds, kind);
|
|
||||||
Map<UUID, UUID> result = new LinkedHashMap<>();
|
|
||||||
for (Object[] row : rows) {
|
|
||||||
UUID docId = (UUID) row[0];
|
|
||||||
UUID actorId = (UUID) row[1];
|
|
||||||
result.put(docId, actorId);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
Map<UUID, List<ActivityActorDTO>> result = new LinkedHashMap<>();
|
|
||||||
for (ContributorRow row : rows) {
|
|
||||||
result.computeIfAbsent(row.getDocumentId(), k -> new ArrayList<>())
|
|
||||||
.add(new ActivityActorDTO(row.getActorInitials(), row.getActorColor(), row.getActorName()));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,5 +5,4 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
public interface AuditLogRepository extends JpaRepository<AuditLog, UUID> {
|
||||||
boolean existsByKind(AuditKind kind);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ package org.raddatz.familienarchiv.audit;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.core.task.TaskExecutor;
|
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
@@ -18,8 +16,6 @@ import java.util.UUID;
|
|||||||
public class AuditService {
|
public class AuditService {
|
||||||
|
|
||||||
private final AuditLogRepository auditLogRepository;
|
private final AuditLogRepository auditLogRepository;
|
||||||
@Qualifier("auditExecutor")
|
|
||||||
private final TaskExecutor auditExecutor;
|
|
||||||
|
|
||||||
@Async("auditExecutor")
|
@Async("auditExecutor")
|
||||||
public void log(AuditKind kind, UUID actorId, UUID documentId, Map<String, Object> payload) {
|
public void log(AuditKind kind, UUID actorId, UUID documentId, Map<String, Object> payload) {
|
||||||
@@ -31,10 +27,7 @@ public class AuditService {
|
|||||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
@Override
|
@Override
|
||||||
public void afterCommit() {
|
public void afterCommit() {
|
||||||
// Run on a separate thread: the afterCommit() callback fires while Spring's
|
writeLog(kind, actorId, documentId, payload);
|
||||||
// transaction synchronizations are still active on the current thread, which
|
|
||||||
// prevents SimpleJpaRepository.save() from starting a new transaction inline.
|
|
||||||
auditExecutor.execute(() -> writeLog(kind, actorId, documentId, payload));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface ContributorRow {
|
|
||||||
UUID getDocumentId();
|
|
||||||
String getActorInitials();
|
|
||||||
String getActorColor();
|
|
||||||
String getActorName();
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
public interface PulseStatsRow {
|
|
||||||
long getPages();
|
|
||||||
long getAnnotated();
|
|
||||||
long getTranscribed();
|
|
||||||
long getUploaded();
|
|
||||||
long getYourPages();
|
|
||||||
}
|
|
||||||
@@ -31,24 +31,6 @@ public class AsyncConfig {
|
|||||||
executor.setMaxPoolSize(2);
|
executor.setMaxPoolSize(2);
|
||||||
executor.setQueueCapacity(50);
|
executor.setQueueCapacity(50);
|
||||||
executor.setThreadNamePrefix("Audit-");
|
executor.setThreadNamePrefix("Audit-");
|
||||||
// AbortPolicy instead of CallerRunsPolicy: if CallerRunsPolicy ran the task on the
|
|
||||||
// afterCommit() callback thread, Spring's transaction synchronizations would still be
|
|
||||||
// active on that thread and SimpleJpaRepository.save() would throw IllegalStateException.
|
|
||||||
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());
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||||
return executor;
|
return executor;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import org.raddatz.familienarchiv.security.RequirePermission;
|
|||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
import org.raddatz.familienarchiv.service.MassImportService;
|
import org.raddatz.familienarchiv.service.MassImportService;
|
||||||
import org.raddatz.familienarchiv.service.ThumbnailBackfillService;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -24,7 +23,6 @@ public class AdminController {
|
|||||||
private final MassImportService massImportService;
|
private final MassImportService massImportService;
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
private final DocumentVersionService documentVersionService;
|
private final DocumentVersionService documentVersionService;
|
||||||
private final ThumbnailBackfillService thumbnailBackfillService;
|
|
||||||
|
|
||||||
@PostMapping("/trigger-import")
|
@PostMapping("/trigger-import")
|
||||||
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
||||||
@@ -49,15 +47,4 @@ public class AdminController {
|
|||||||
int count = documentService.backfillFileHashes();
|
int count = documentService.backfillFileHashes();
|
||||||
return ResponseEntity.ok(new BackfillResult(count));
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,67 @@ public class CommentController {
|
|||||||
private final CommentService commentService;
|
private final CommentService commentService;
|
||||||
private final UserService userService;
|
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 ────────────────────────────────────────
|
// ─── Block (transcription) comments ────────────────────────────────────────
|
||||||
|
|
||||||
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -14,23 +13,10 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.Max;
|
|
||||||
import jakarta.validation.constraints.Min;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
import org.springframework.validation.annotation.Validated;
|
|
||||||
import org.raddatz.familienarchiv.dto.BatchMetadataRequest;
|
|
||||||
import org.raddatz.familienarchiv.dto.BulkEditError;
|
|
||||||
import org.raddatz.familienarchiv.dto.BulkEditResult;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
@@ -75,7 +61,6 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
@RequestMapping("/api/documents")
|
@RequestMapping("/api/documents")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Validated
|
|
||||||
public class DocumentController {
|
public class DocumentController {
|
||||||
|
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
@@ -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 ---
|
// --- METADATA ---
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Document getDocument(@PathVariable UUID id) {
|
public Document getDocument(@PathVariable UUID id) {
|
||||||
@@ -201,7 +161,6 @@ public class DocumentController {
|
|||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public QuickUploadResult quickUpload(
|
public QuickUploadResult quickUpload(
|
||||||
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
@RequestPart(value = "files", required = false) List<MultipartFile> files,
|
||||||
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
|
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
List<Document> created = new ArrayList<>();
|
List<Document> created = new ArrayList<>();
|
||||||
List<Document> updated = new ArrayList<>();
|
List<Document> updated = new ArrayList<>();
|
||||||
@@ -211,21 +170,14 @@ public class DocumentController {
|
|||||||
return new QuickUploadResult(created, updated, errors);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
documentService.validateBatch(files.size(), metadata);
|
|
||||||
|
|
||||||
UUID actorId = requireUserId(authentication);
|
UUID actorId = requireUserId(authentication);
|
||||||
long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();
|
for (MultipartFile file : files) {
|
||||||
|
|
||||||
for (int i = 0; i < files.size(); i++) {
|
|
||||||
MultipartFile file = files.get(i);
|
|
||||||
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||||
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
DocumentService.StoreResult result = metadata != null
|
DocumentService.StoreResult result = documentService.storeDocument(file, actorId);
|
||||||
? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
|
|
||||||
: documentService.storeDocument(file, actorId);
|
|
||||||
if (result.isNew()) {
|
if (result.isNew()) {
|
||||||
created.add(result.document());
|
created.add(result.document());
|
||||||
} else {
|
} else {
|
||||||
@@ -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);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- BULK EDIT ---
|
|
||||||
|
|
||||||
private static final int BULK_EDIT_MAX_IDS = 500;
|
|
||||||
/** Hard cap for {@code GET /api/documents/ids}: prevents an unfiltered
|
|
||||||
* call from materialising the entire {@code documents} table into JSON.
|
|
||||||
* Generous enough for real-world "Alle X editieren" against the family
|
|
||||||
* archive's bounded scale (~1500 docs today, expected growth to ~5k). */
|
|
||||||
private static final int BULK_EDIT_FILTER_MAX_IDS = 5000;
|
|
||||||
|
|
||||||
@PatchMapping("/bulk")
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public BulkEditResult patchBulk(
|
|
||||||
@RequestBody @Valid DocumentBulkEditDTO dto,
|
|
||||||
Authentication authentication) {
|
|
||||||
if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
|
|
||||||
}
|
|
||||||
if (dto.getDocumentIds().size() > BULK_EDIT_MAX_IDS) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
|
||||||
"Maximum " + BULK_EDIT_MAX_IDS + " documents per request, got: " + dto.getDocumentIds().size());
|
|
||||||
}
|
|
||||||
|
|
||||||
UUID actorId = requireUserId(authentication);
|
|
||||||
int updated = 0;
|
|
||||||
List<BulkEditError> errors = new ArrayList<>();
|
|
||||||
|
|
||||||
// Dedupe duplicate document IDs while preserving submission order. A
|
|
||||||
// double-click on "Alle X editieren" would otherwise hit each document
|
|
||||||
// twice and inflate the `updated` count returned to the user.
|
|
||||||
LinkedHashSet<UUID> uniqueIds = new LinkedHashSet<>(dto.getDocumentIds());
|
|
||||||
|
|
||||||
for (UUID id : uniqueIds) {
|
|
||||||
try {
|
|
||||||
documentService.applyBulkEditToDocument(id, dto, actorId);
|
|
||||||
updated++;
|
|
||||||
} catch (DomainException e) {
|
|
||||||
errors.add(new BulkEditError(id, sanitizeForLog(e.getMessage())));
|
|
||||||
} catch (Exception e) {
|
|
||||||
errors.add(new BulkEditError(id, "Internal error"));
|
|
||||||
log.warn("Bulk edit failed for document {}: {}", id, sanitizeForLog(e.getMessage()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("bulkEdit actor={} documentIds={} unique={} updated={} errors={}",
|
|
||||||
actorId, dto.getDocumentIds().size(), uniqueIds.size(), updated, errors.size());
|
|
||||||
|
|
||||||
return new BulkEditResult(updated, errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CRLF strip for any log line interpolating a free-form string (e.g.
|
|
||||||
* {@link Throwable#getMessage()}). Defends against CWE-117 log injection. */
|
|
||||||
private static String sanitizeForLog(String s) {
|
|
||||||
return s == null ? null : s.replaceAll("[\\r\\n]", "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/ids")
|
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public List<UUID> getDocumentIds(
|
|
||||||
@RequestParam(required = false) String q,
|
|
||||||
@RequestParam(required = false) LocalDate from,
|
|
||||||
@RequestParam(required = false) LocalDate to,
|
|
||||||
@RequestParam(required = false) UUID senderId,
|
|
||||||
@RequestParam(required = false) UUID receiverId,
|
|
||||||
@RequestParam(required = false, name = "tag") List<String> tags,
|
|
||||||
@RequestParam(required = false) String tagQ,
|
|
||||||
@RequestParam(required = false) DocumentStatus status,
|
|
||||||
@RequestParam(required = false) String tagOp,
|
|
||||||
Authentication authentication) {
|
|
||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
|
||||||
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator);
|
|
||||||
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
|
||||||
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
|
|
||||||
}
|
|
||||||
UUID actorId = requireUserId(authentication);
|
|
||||||
log.info("documentIds actor={} matched={}", actorId, ids.size());
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE)
|
|
||||||
@RequirePermission(Permission.READ_ALL)
|
|
||||||
public List<DocumentBatchSummary> batchMetadata(@RequestBody @Valid BatchMetadataRequest request, Authentication authentication) {
|
|
||||||
if (request == null || request.ids() == null || request.ids().isEmpty()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required");
|
|
||||||
}
|
|
||||||
if (request.ids().size() > BULK_EDIT_MAX_IDS) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
|
||||||
"Maximum " + BULK_EDIT_MAX_IDS + " ids per request, got: " + request.ids().size());
|
|
||||||
}
|
|
||||||
UUID actorId = requireUserId(authentication);
|
|
||||||
log.info("batchMetadata actor={} ids={}", actorId, request.ids().size());
|
|
||||||
return documentService.batchMetadata(request.ids());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/incomplete-count")
|
@GetMapping("/incomplete-count")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public Map<String, Long> getIncompleteCount() {
|
public Map<String, Long> getIncompleteCount() {
|
||||||
return Map.of("count", documentService.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")
|
@GetMapping("/incomplete/next")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
|
||||||
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
||||||
return documentService.findNextIncompleteDocument(excludeId)
|
return documentService.findNextIncompleteDocument(excludeId)
|
||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
@@ -372,20 +216,14 @@ public class DocumentController {
|
|||||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
|
||||||
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
|
||||||
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
|
||||||
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
|
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) {
|
||||||
// @Max on page guards against overflow when pageable.getOffset() is computed
|
|
||||||
// as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
|
|
||||||
// Hibernate cheerfully turns into an invalid SQL OFFSET.
|
|
||||||
@Parameter(description = "Page number (0-indexed)") @RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page,
|
|
||||||
@Parameter(description = "Page size (max 100)") @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
|
|
||||||
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
|
||||||
}
|
}
|
||||||
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
||||||
// defaults to AND, which matches the frontend default and keeps old clients working.
|
// defaults to AND, which matches the frontend default and keeps old clients working.
|
||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||||
Pageable pageable = PageRequest.of(page, size);
|
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator));
|
||||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- TRAINING LABELS ---
|
// --- TRAINING LABELS ---
|
||||||
|
|||||||
@@ -63,33 +63,27 @@ public class PersonController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
validatePersonNames(dto);
|
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||||
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
|| 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());
|
dto.setLastName(dto.getLastName().trim());
|
||||||
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
|
||||||
return ResponseEntity.ok(personService.createPerson(dto));
|
return ResponseEntity.ok(personService.createPerson(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
validatePersonNames(dto);
|
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||||
if (dto.getFirstName() != null) dto.setFirstName(dto.getFirstName().trim());
|
|| 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());
|
dto.setLastName(dto.getLastName().trim());
|
||||||
if (dto.getTitle() != null) dto.setTitle(dto.getTitle().trim());
|
|
||||||
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
return ResponseEntity.ok(personService.updatePerson(id, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validatePersonNames(PersonUpdateDTO dto) {
|
|
||||||
if (dto.getLastName() == null || dto.getLastName().isBlank()) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nachname ist Pflichtfeld");
|
|
||||||
}
|
|
||||||
if (dto.getPersonType() == org.raddatz.familienarchiv.model.PersonType.PERSON
|
|
||||||
&& (dto.getFirstName() == null || dto.getFirstName().isBlank())) {
|
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vorname ist Pflichtfeld");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/merge")
|
@PostMapping("/{id}/merge")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
|||||||
@@ -78,31 +78,24 @@ public class UserController {
|
|||||||
|
|
||||||
@PostMapping("/users")
|
@PostMapping("/users")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> createUser(Authentication authentication,
|
public ResponseEntity<AppUser> createUser(@Valid @RequestBody CreateUserRequest request) {
|
||||||
@Valid @RequestBody CreateUserRequest request) {
|
return ResponseEntity.ok(userService.createUserOrUpdate(request));
|
||||||
return ResponseEntity.ok(userService.createUserOrUpdate(actorId(authentication), request));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/users/{id}")
|
@PutMapping("/users/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> adminUpdateUser(Authentication authentication,
|
public ResponseEntity<AppUser> adminUpdateUser(@PathVariable UUID id,
|
||||||
@PathVariable UUID id,
|
|
||||||
@RequestBody AdminUpdateUserRequest dto) {
|
@RequestBody AdminUpdateUserRequest dto) {
|
||||||
AppUser updated = userService.adminUpdateUser(actorId(authentication), id, dto);
|
AppUser updated = userService.adminUpdateUser(id, dto);
|
||||||
updated.setPassword(null);
|
updated.setPassword(null);
|
||||||
return ResponseEntity.ok(updated);
|
return ResponseEntity.ok(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/users/{id}")
|
@DeleteMapping("/users/{id}")
|
||||||
@RequirePermission(Permission.ADMIN_USER)
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<Void> deleteUser(Authentication authentication,
|
public ResponseEntity<Void> deleteUser(@PathVariable UUID id) {
|
||||||
@PathVariable UUID id) {
|
userService.deleteUser(id);
|
||||||
userService.deleteUser(actorId(authentication), id);
|
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private UUID actorId(Authentication auth) {
|
|
||||||
return userService.findByEmail(auth.getName()).getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.annotation.Nullable;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record ActivityFeedItemDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) AuditKind kind,
|
|
||||||
@Nullable ActivityActorDTO actor,
|
|
||||||
@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
|
|
||||||
) {}
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package org.raddatz.familienarchiv.dashboard;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditLog;
|
||||||
|
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.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
SELECT a.document_id
|
||||||
|
FROM audit_log a
|
||||||
|
WHERE a.kind = 'TEXT_SAVED'
|
||||||
|
AND a.actor_id = :userId
|
||||||
|
AND a.document_id IS NOT NULL
|
||||||
|
ORDER BY a.happened_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""", nativeQuery = true)
|
||||||
|
Optional<UUID> findMostRecentDocumentIdByActor(@Param("userId") UUID userId);
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
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 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 happenedAt,
|
||||||
|
(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> findDedupedActivityFeed(
|
||||||
|
@Param("currentUserId") String currentUserId,
|
||||||
|
@Param("limit") int limit);
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber'))) AS pages,
|
||||||
|
COUNT(*) FILTER (WHERE a.kind = 'ANNOTATION_CREATED') AS annotated,
|
||||||
|
COUNT(DISTINCT a.payload->>'blockId') FILTER (WHERE a.kind = 'TEXT_SAVED') AS transcribed,
|
||||||
|
COUNT(DISTINCT a.document_id) FILTER (WHERE a.kind = 'FILE_UPLOADED') AS uploaded,
|
||||||
|
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber')))
|
||||||
|
FILTER (WHERE (a.kind = 'ANNOTATION_CREATED' OR a.kind = 'TEXT_SAVED')
|
||||||
|
AND a.actor_id::text = :userId) AS yourPages
|
||||||
|
FROM audit_log a
|
||||||
|
WHERE a.happened_at >= :weekStart
|
||||||
|
AND a.kind IN ('ANNOTATION_CREATED','TEXT_SAVED','FILE_UPLOADED')
|
||||||
|
""", nativeQuery = true)
|
||||||
|
PulseStatsRow getPulseStats(
|
||||||
|
@Param("weekStart") OffsetDateTime weekStart,
|
||||||
|
@Param("userId") String userId);
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
SELECT DISTINCT ON (a.document_id)
|
||||||
|
a.document_id AS documentId,
|
||||||
|
a.actor_id AS actorId
|
||||||
|
FROM audit_log a
|
||||||
|
WHERE a.kind = :kind
|
||||||
|
AND a.document_id IN :documentIds
|
||||||
|
AND a.actor_id IS NOT NULL
|
||||||
|
ORDER BY a.document_id, a.happened_at DESC
|
||||||
|
""", nativeQuery = true)
|
||||||
|
List<Object[]> findMostRecentActorPerDocument(
|
||||||
|
@Param("documentIds") List<UUID> documentIds,
|
||||||
|
@Param("kind") String kind);
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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;
|
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
|
||||||
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
|
|
||||||
@RequestMapping("/api/dashboard")
|
|
||||||
@RequirePermission(Permission.READ_ALL)
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DashboardController {
|
|
||||||
|
|
||||||
private final DashboardService dashboardService;
|
|
||||||
private final UserService userService;
|
|
||||||
|
|
||||||
@GetMapping("/resume")
|
|
||||||
public DashboardResumeDTO getResume(Authentication authentication) {
|
|
||||||
UUID userId = SecurityUtils.requireUserId(authentication, userService);
|
|
||||||
return dashboardService.getResume(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/pulse")
|
|
||||||
public DashboardPulseDTO getPulse(Authentication authentication) {
|
|
||||||
UUID userId = SecurityUtils.requireUserId(authentication, userService);
|
|
||||||
return dashboardService.getPulse(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record DashboardPulseDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pages,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotated,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int transcribed,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int uploaded,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int yourPages,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> contributors
|
|
||||||
) {}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import jakarta.annotation.Nullable;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record DashboardResumeDTO(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID documentId,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String caption,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String excerpt,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int totalBlocks,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int pct,
|
|
||||||
@Nullable String thumbnailUrl,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> collaborators
|
|
||||||
) {}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
|
||||||
|
|
||||||
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;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.DayOfWeek;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.time.temporal.TemporalAdjusters;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class DashboardService {
|
|
||||||
|
|
||||||
private final AuditLogQueryService auditLogQueryService;
|
|
||||||
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);
|
|
||||||
if (docIdOpt.isEmpty()) return null;
|
|
||||||
|
|
||||||
UUID docId = docIdOpt.get();
|
|
||||||
Document doc;
|
|
||||||
try {
|
|
||||||
doc = documentService.getDocumentById(docId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Resume: document {} not found for user {}", docId, userId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<TranscriptionBlock> blocks = transcriptionService.listBlocks(docId);
|
|
||||||
String excerpt = blocks.stream()
|
|
||||||
.filter(b -> b.getText() != null && !b.getText().isBlank())
|
|
||||||
.min(Comparator.comparingInt(TranscriptionBlock::getSortOrder))
|
|
||||||
.map(b -> b.getText().length() > 200 ? b.getText().substring(0, 200) + "…" : b.getText())
|
|
||||||
.orElse("");
|
|
||||||
|
|
||||||
int totalBlocks = blocks.size();
|
|
||||||
long reviewedBlocks = blocks.stream().filter(TranscriptionBlock::isReviewed).count();
|
|
||||||
int pct = totalBlocks > 0 ? (int) (reviewedBlocks * 100L / totalBlocks) : 0;
|
|
||||||
|
|
||||||
String caption = buildCaption(doc);
|
|
||||||
|
|
||||||
List<UUID> collaboratorIds = blocks.stream()
|
|
||||||
.map(TranscriptionBlock::getUpdatedBy)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.distinct()
|
|
||||||
.limit(5)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
List<ActivityActorDTO> collaborators = collaboratorIds.stream()
|
|
||||||
.map(uid -> {
|
|
||||||
try {
|
|
||||||
AppUser u = userService.getById(uid);
|
|
||||||
return toActorDTO(u);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
|
|
||||||
totalBlocks, pct, doc.getThumbnailUrl(), collaborators);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DashboardPulseDTO getPulse(UUID userId) {
|
|
||||||
OffsetDateTime weekStart = OffsetDateTime.now(ZoneOffset.UTC)
|
|
||||||
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
|
||||||
.withHour(0).withMinute(0).withSecond(0).withNano(0);
|
|
||||||
|
|
||||||
PulseStatsRow stats = auditLogQueryService.getPulseStats(weekStart, userId);
|
|
||||||
|
|
||||||
List<ActivityFeedRow> feed = auditLogQueryService.findActivityFeed(userId, 50);
|
|
||||||
List<ActivityActorDTO> contributors = feed.stream()
|
|
||||||
.filter(r -> r.getActorId() != null)
|
|
||||||
.map(r -> new ActivityActorDTO(r.getActorInitials(), r.getActorColor(), r.getActorName()))
|
|
||||||
.filter(a -> !a.initials().isBlank())
|
|
||||||
.distinct()
|
|
||||||
.limit(6)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return new DashboardPulseDTO(
|
|
||||||
(int) stats.getPages(),
|
|
||||||
(int) stats.getAnnotated(),
|
|
||||||
(int) stats.getTranscribed(),
|
|
||||||
(int) stats.getUploaded(),
|
|
||||||
(int) stats.getYourPages(),
|
|
||||||
contributors
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<ActivityFeedItemDTO> getActivity(UUID currentUserId, int limit, Set<AuditKind> kinds) {
|
|
||||||
List<ActivityFeedRow> rows = auditLogQueryService.findActivityFeed(currentUserId, limit, kinds);
|
|
||||||
|
|
||||||
List<UUID> docIds = rows.stream()
|
|
||||||
.map(ActivityFeedRow::getDocumentId)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.distinct()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
Map<UUID, String> titleCache = new HashMap<>();
|
|
||||||
try {
|
|
||||||
documentService.getDocumentsByIds(docIds)
|
|
||||||
.forEach(d -> titleCache.put(d.getId(), d.getTitle()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildCaption(Document doc) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
if (doc.getSender() != null) sb.append(personName(doc.getSender()));
|
|
||||||
if (!doc.getReceivers().isEmpty()) {
|
|
||||||
String receivers = doc.getReceivers().stream()
|
|
||||||
.map(this::personName).collect(Collectors.joining(", "));
|
|
||||||
if (!sb.isEmpty()) sb.append(" an ");
|
|
||||||
sb.append(receivers);
|
|
||||||
}
|
|
||||||
if (doc.getDocumentDate() != null) {
|
|
||||||
if (!sb.isEmpty()) sb.append(" · ");
|
|
||||||
sb.append(doc.getDocumentDate());
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String personName(Person p) {
|
|
||||||
if (p == null) return "";
|
|
||||||
if (p.getFirstName() != null && p.getLastName() != null) return p.getFirstName() + " " + p.getLastName();
|
|
||||||
if (p.getFirstName() != null) return p.getFirstName();
|
|
||||||
if (p.getLastName() != null) return p.getLastName();
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private ActivityActorDTO toActorDTO(AppUser u) {
|
|
||||||
String initials = "";
|
|
||||||
if (u.getFirstName() != null && !u.getFirstName().isBlank())
|
|
||||||
initials += u.getFirstName().charAt(0);
|
|
||||||
if (u.getLastName() != null && !u.getLastName().isBlank())
|
|
||||||
initials += u.getLastName().charAt(0);
|
|
||||||
if (initials.isBlank() && u.getEmail() != null)
|
|
||||||
initials = u.getEmail().substring(0, 1).toUpperCase();
|
|
||||||
String fullName = Stream.of(u.getFirstName(), u.getLastName())
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.collect(Collectors.joining(" "));
|
|
||||||
return new ActivityActorDTO(initials.toUpperCase(), u.getColor(), fullName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) {}
|
|
||||||
@@ -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) {}
|
|
||||||
@@ -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) {}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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) {}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
) {}
|
|
||||||
@@ -1,38 +1,35 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public record DocumentSearchResult(
|
public record DocumentSearchResult(
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
List<DocumentSearchItem> items,
|
List<Document> documents,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
long totalElements,
|
long total,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
int pageNumber,
|
Map<UUID, SearchMatchData> matchData
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
int pageSize,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
int totalPages
|
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
* Creates a fully-enriched result from documents and their match overlay data.
|
||||||
* don't care about paging. Treats the whole list as page 0 of itself.
|
* 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) {
|
public static DocumentSearchResult withMatchData(List<Document> documents, Map<UUID, SearchMatchData> matchData) {
|
||||||
int size = items.size();
|
return new DocumentSearchResult(documents, documents.size(), matchData);
|
||||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paged factory used by the service when it has a real Pageable + full match count
|
* Creates a result without match data — used for filter-only searches (no text query).
|
||||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
* 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) {
|
public static DocumentSearchResult of(List<Document> documents) {
|
||||||
int pageSize = pageable.getPageSize();
|
return withMatchData(documents, Map.of());
|
||||||
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
|
||||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ public class DocumentUpdateDTO {
|
|||||||
private LocalDate documentDate;
|
private LocalDate documentDate;
|
||||||
private String location;
|
private String location;
|
||||||
private String documentLocation;
|
private String documentLocation;
|
||||||
private String archiveBox;
|
|
||||||
private String archiveFolder;
|
|
||||||
private String transcription;
|
private String transcription;
|
||||||
private String summary;
|
private String summary;
|
||||||
private UUID senderId;
|
private UUID senderId;
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ package org.raddatz.familienarchiv.dto;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record IncompleteDocumentDTO(
|
public record IncompleteDocumentDTO(
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime uploadedAt
|
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.raddatz.familienarchiv.model.PersonType;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PersonUpdateDTO {
|
public class PersonUpdateDTO {
|
||||||
@NotNull
|
|
||||||
private PersonType personType;
|
|
||||||
@Size(max = 50)
|
@Size(max = 50)
|
||||||
private String title;
|
private String title;
|
||||||
@Size(max = 100)
|
@Size(max = 100)
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single row in one of the three Mission Control Strip queues.
|
||||||
|
* Annotation/block counts drive the per-document mini progress bar
|
||||||
|
* in the Transkription column and the percentage label in Lesefertig.
|
||||||
|
*/
|
||||||
public record TranscriptionQueueItemDTO(
|
public record TranscriptionQueueItemDTO(
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||||
LocalDate documentDate,
|
LocalDate documentDate,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotationCount,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int annotationCount,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int textedBlockCount,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int textedBlockCount,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int reviewedBlockCount,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int reviewedBlockCount
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<ActivityActorDTO> contributors,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean hasMoreContributors
|
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ public enum ErrorCode {
|
|||||||
PERSON_NOT_FOUND,
|
PERSON_NOT_FOUND,
|
||||||
/** A person name alias with the given ID does not exist. 404 */
|
/** A person name alias with the given ID does not exist. 404 */
|
||||||
ALIAS_NOT_FOUND,
|
ALIAS_NOT_FOUND,
|
||||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
|
||||||
INVALID_PERSON_TYPE,
|
|
||||||
|
|
||||||
// --- Documents ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
@@ -40,10 +38,6 @@ public enum ErrorCode {
|
|||||||
/** A mass import is already in progress; only one can run at a time. 409 */
|
/** A mass import is already in progress; only one can run at a time. 409 */
|
||||||
IMPORT_ALREADY_RUNNING,
|
IMPORT_ALREADY_RUNNING,
|
||||||
|
|
||||||
// --- Thumbnails ---
|
|
||||||
/** A thumbnail backfill is already in progress; only one can run at a time. 409 */
|
|
||||||
THUMBNAIL_BACKFILL_ALREADY_RUNNING,
|
|
||||||
|
|
||||||
// --- Invites ---
|
// --- Invites ---
|
||||||
/** The invite code does not exist. 404 */
|
/** The invite code does not exist. 404 */
|
||||||
INVITE_NOT_FOUND,
|
INVITE_NOT_FOUND,
|
||||||
@@ -111,10 +105,6 @@ public enum ErrorCode {
|
|||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
/** Batch upload exceeds the maximum allowed file count per request. 400 */
|
|
||||||
BATCH_TOO_LARGE,
|
|
||||||
/** Bulk edit request exceeds the per-request document ID cap. 400 */
|
|
||||||
BULK_EDIT_TOO_MANY_IDS,
|
|
||||||
/** An unexpected server-side error occurred. 500 */
|
/** An unexpected server-side error occurred. 500 */
|
||||||
INTERNAL_ERROR,
|
INTERNAL_ERROR,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
@@ -46,20 +43,6 @@ public class Document {
|
|||||||
@Column(name = "file_hash", length = 64)
|
@Column(name = "file_hash", length = 64)
|
||||||
private String fileHash;
|
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")
|
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
||||||
@Column(name = "original_filename", nullable = false)
|
@Column(name = "original_filename", nullable = false)
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@@ -134,19 +117,4 @@ public class Document {
|
|||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
|
||||||
|
|
||||||
public enum ThumbnailAspect {
|
|
||||||
PORTRAIT,
|
|
||||||
LANDSCAPE
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,10 @@ import java.util.UUID;
|
|||||||
|
|
||||||
public interface CommentRepository extends JpaRepository<DocumentComment, 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> findByParentId(UUID parentId);
|
||||||
|
|
||||||
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);
|
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.repository;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface CompletionStatsRow {
|
|
||||||
UUID getDocumentId();
|
|
||||||
int getCompletionPercentage();
|
|
||||||
}
|
|
||||||
@@ -46,8 +46,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||||
|
|
||||||
List<Document> findByFilePathIsNotNullAndThumbnailKeyIsNull();
|
|
||||||
|
|
||||||
@Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids")
|
@Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids")
|
||||||
List<Object[]> findIdAndTitleByIdIn(@Param("ids") Collection<UUID> 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
|
SELECT d.id FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||||
THEN to_tsquery('simple', regexp_replace(
|
THEN to_tsquery('german', regexp_replace(
|
||||||
websearch_to_tsquery('german', :query)::text,
|
websearch_to_tsquery('german', :query)::text,
|
||||||
'''([^'']+)''',
|
'''([^'']+)''',
|
||||||
'''\\1'':*',
|
'''\\1'':*',
|
||||||
@@ -149,7 +147,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
FROM documents d
|
FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
SELECT CASE WHEN websearch_to_tsquery('german', :query)::text <> ''
|
||||||
THEN to_tsquery('simple', regexp_replace(
|
THEN to_tsquery('german', regexp_replace(
|
||||||
websearch_to_tsquery('german', :query)::text,
|
websearch_to_tsquery('german', :query)::text,
|
||||||
'''([^'']+)''',
|
'''([^'']+)''',
|
||||||
'''\\1'':*',
|
'''\\1'':*',
|
||||||
|
|||||||
@@ -5,24 +5,12 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface TranscriptionBlockRepository extends JpaRepository<TranscriptionBlock, 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);
|
List<TranscriptionBlock> findByDocumentIdOrderBySortOrderAsc(UUID documentId);
|
||||||
|
|
||||||
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);
|
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||||
|
|||||||
@@ -8,13 +8,10 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
|||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
|
||||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -29,15 +26,16 @@ public class CommentService {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final TranscriptionService transcriptionService;
|
|
||||||
|
|
||||||
public Map<UUID, UUID> findAnnotationIdsByIds(Collection<UUID> commentIds) {
|
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
||||||
if (commentIds == null || commentIds.isEmpty()) return Map.of();
|
List<DocumentComment> roots =
|
||||||
Map<UUID, UUID> result = new HashMap<>();
|
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
||||||
for (DocumentComment c : commentRepository.findAllById(commentIds)) {
|
return withRepliesAndMentions(roots);
|
||||||
if (c.getAnnotationId() != null) result.put(c.getId(), c.getAnnotationId());
|
}
|
||||||
}
|
|
||||||
return result;
|
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
||||||
|
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
||||||
|
return withRepliesAndMentions(roots);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
||||||
@@ -48,11 +46,27 @@ public class CommentService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
|
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
|
||||||
List<UUID> mentionedUserIds, AppUser author) {
|
List<UUID> mentionedUserIds, AppUser author) {
|
||||||
TranscriptionBlock block = transcriptionService.getBlock(documentId, blockId);
|
|
||||||
DocumentComment comment = DocumentComment.builder()
|
DocumentComment comment = DocumentComment.builder()
|
||||||
.documentId(documentId)
|
.documentId(documentId)
|
||||||
.blockId(blockId)
|
.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)
|
.content(content)
|
||||||
.authorId(author.getId())
|
.authorId(author.getId())
|
||||||
.authorName(resolveAuthorName(author))
|
.authorName(resolveAuthorName(author))
|
||||||
|
|||||||
@@ -3,14 +3,8 @@ package org.raddatz.familienarchiv.service;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
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.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
import org.springframework.data.domain.Page;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -67,9 +59,6 @@ public class DocumentService {
|
|||||||
private final DocumentVersionService documentVersionService;
|
private final DocumentVersionService documentVersionService;
|
||||||
private final AnnotationService annotationService;
|
private final AnnotationService annotationService;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
|
||||||
private final AuditLogQueryService auditLogQueryService;
|
|
||||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
|
||||||
|
|
||||||
public record StoreResult(Document document, boolean isNew) {}
|
public record StoreResult(Document document, boolean isNew) {}
|
||||||
|
|
||||||
@@ -131,56 +120,9 @@ public class DocumentService {
|
|||||||
if (wasPlaceholder) {
|
if (wasPlaceholder) {
|
||||||
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
|
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
|
||||||
}
|
}
|
||||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
|
||||||
return new StoreResult(saved, isNew);
|
return new StoreResult(saved, isNew);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
|
|
||||||
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
|
|
||||||
if (fileCount > 50) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
|
|
||||||
}
|
|
||||||
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public StoreResult storeDocumentWithBatchMetadata(
|
|
||||||
MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {
|
|
||||||
StoreResult base = storeDocument(file, actorId);
|
|
||||||
Document doc = applyBatchMetadata(base.document(), metadata, fileIndex);
|
|
||||||
return new StoreResult(documentRepository.save(doc), base.isNew());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) {
|
|
||||||
if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) {
|
|
||||||
doc.setTitle(metadata.getTitles().get(fileIndex));
|
|
||||||
}
|
|
||||||
if (metadata.getSenderId() != null) {
|
|
||||||
doc.setSender(personService.getById(metadata.getSenderId()));
|
|
||||||
}
|
|
||||||
if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) {
|
|
||||||
doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds())));
|
|
||||||
}
|
|
||||||
if (metadata.getDocumentDate() != null) {
|
|
||||||
doc.setDocumentDate(metadata.getDocumentDate());
|
|
||||||
}
|
|
||||||
if (metadata.getLocation() != null) {
|
|
||||||
doc.setLocation(metadata.getLocation());
|
|
||||||
}
|
|
||||||
if (metadata.getMetadataComplete() != null) {
|
|
||||||
doc.setMetadataComplete(metadata.getMetadataComplete());
|
|
||||||
}
|
|
||||||
if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) {
|
|
||||||
UUID docId = doc.getId();
|
|
||||||
updateDocumentTags(docId, metadata.getTagNames());
|
|
||||||
doc = documentRepository.findById(docId)
|
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId));
|
|
||||||
}
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
|
||||||
String filename = (file != null && !file.isEmpty())
|
String filename = (file != null && !file.isEmpty())
|
||||||
@@ -240,8 +182,7 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Datei
|
// Datei
|
||||||
boolean fileUploaded = file != null && !file.isEmpty();
|
if (file != null && !file.isEmpty()) {
|
||||||
if (fileUploaded) {
|
|
||||||
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
|
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
|
||||||
doc.setFilePath(upload.s3Key());
|
doc.setFilePath(upload.s3Key());
|
||||||
doc.setFileHash(upload.fileHash());
|
doc.setFileHash(upload.fileHash());
|
||||||
@@ -251,9 +192,6 @@ public class DocumentService {
|
|||||||
|
|
||||||
Document finalDoc = documentRepository.save(doc);
|
Document finalDoc = documentRepository.save(doc);
|
||||||
documentVersionService.recordVersion(finalDoc);
|
documentVersionService.recordVersion(finalDoc);
|
||||||
if (fileUploaded) {
|
|
||||||
thumbnailAsyncRunner.dispatchAfterCommit(finalDoc.getId());
|
|
||||||
}
|
|
||||||
return finalDoc;
|
return finalDoc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,8 +209,6 @@ public class DocumentService {
|
|||||||
doc.setTranscription(dto.getTranscription());
|
doc.setTranscription(dto.getTranscription());
|
||||||
doc.setSummary(dto.getSummary());
|
doc.setSummary(dto.getSummary());
|
||||||
doc.setDocumentLocation(dto.getDocumentLocation());
|
doc.setDocumentLocation(dto.getDocumentLocation());
|
||||||
doc.setArchiveBox(dto.getArchiveBox());
|
|
||||||
doc.setArchiveFolder(dto.getArchiveFolder());
|
|
||||||
|
|
||||||
List<String> tags = new ArrayList<>();
|
List<String> tags = new ArrayList<>();
|
||||||
if (dto.getTags() != null && !dto.getTags().isBlank()) {
|
if (dto.getTags() != null && !dto.getTags().isBlank()) {
|
||||||
@@ -308,8 +244,7 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||||
boolean fileReplaced = newFile != null && !newFile.isEmpty();
|
if (newFile != null && !newFile.isEmpty()) {
|
||||||
if (fileReplaced) {
|
|
||||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||||
doc.setFilePath(upload.s3Key());
|
doc.setFilePath(upload.s3Key());
|
||||||
doc.setFileHash(upload.fileHash());
|
doc.setFileHash(upload.fileHash());
|
||||||
@@ -328,153 +263,26 @@ public class DocumentService {
|
|||||||
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), null);
|
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileReplaced) {
|
|
||||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||||
Document doc = documentRepository.findById(docId)
|
Document doc = documentRepository.findById(docId)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
||||||
doc.setTags(resolveTags(tagNames));
|
|
||||||
return documentRepository.save(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
Set<Tag> newTags = new HashSet<>();
|
||||||
* Resolves a list of tag-name strings to {@link Tag} entities, trimming
|
|
||||||
* whitespace and skipping blank entries. Single source of truth for
|
|
||||||
* "name string → Tag" so the find-or-create policy stays consistent
|
|
||||||
* across single-doc updates ({@link #updateDocumentTags}), bulk edits
|
|
||||||
* ({@link #applyBulkEditToDocument}), and the upload-batch path
|
|
||||||
* ({@code applyBatchMetadata}).
|
|
||||||
*/
|
|
||||||
private Set<Tag> resolveTags(List<String> tagNames) {
|
|
||||||
if (tagNames == null || tagNames.isEmpty()) return new HashSet<>();
|
|
||||||
Set<Tag> resolved = new HashSet<>();
|
|
||||||
for (String name : tagNames) {
|
for (String name : tagNames) {
|
||||||
|
// Clean the string
|
||||||
String cleanName = name.trim();
|
String cleanName = name.trim();
|
||||||
if (cleanName.isEmpty()) continue;
|
if (cleanName.isEmpty())
|
||||||
resolved.add(tagService.findOrCreate(cleanName));
|
continue;
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
newTags.add(tagService.findOrCreate(cleanName));
|
||||||
* Returns all document IDs matching the given filter parameters, ignoring
|
|
||||||
* pagination. Used by the bulk-edit "Alle X editieren" fast path so the
|
|
||||||
* frontend can replace the selection with every match across pages in one
|
|
||||||
* round-trip.
|
|
||||||
*/
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
|
||||||
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
|
|
||||||
boolean hasText = StringUtils.hasText(text);
|
|
||||||
List<UUID> rankedIds = null;
|
|
||||||
if (hasText) {
|
|
||||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
|
||||||
if (rankedIds.isEmpty()) return List.of();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Specification<Document> spec = buildSearchSpec(
|
doc.setTags(newTags);
|
||||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
return documentRepository.save(doc);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -516,7 +324,6 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
Document saved = documentRepository.save(doc);
|
Document saved = documentRepository.save(doc);
|
||||||
documentVersionService.recordVersion(saved);
|
documentVersionService.recordVersion(saved);
|
||||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
|
||||||
if (wasPlaceholder) {
|
if (wasPlaceholder) {
|
||||||
auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null);
|
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)
|
// 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);
|
boolean hasText = StringUtils.hasText(text);
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
|
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
rankedIds = documentRepository.findRankedIdsByFts(text);
|
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(
|
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
||||||
|
|
||||||
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
|
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
|
||||||
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
Specification<Document> spec = Specification.where(textSpec)
|
||||||
// documents with null sender/receivers; RELEVANCE maps a DB order to an external
|
.and(isBetween(from, to))
|
||||||
// rank list. Cost scales linearly with match count — acceptable while documents
|
.and(hasSender(sender))
|
||||||
// stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
|
.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) {
|
if (sort == DocumentSort.RECEIVER) {
|
||||||
List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
|
List<Document> results = documentRepository.findAll(spec);
|
||||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
List<Document> sorted = sortByFirstReceiver(results, dir);
|
||||||
|
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||||
}
|
}
|
||||||
if (sort == DocumentSort.SENDER) {
|
if (sort == DocumentSort.SENDER) {
|
||||||
List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
|
List<Document> results = documentRepository.findAll(spec);
|
||||||
return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
|
List<Document> sorted = sortBySender(results, dir);
|
||||||
|
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(sorted), enrichWithMatchData(sorted, text));
|
||||||
}
|
}
|
||||||
|
|
||||||
// RELEVANCE: default when text present and no explicit sort given
|
// RELEVANCE: default when text present and no explicit sort given
|
||||||
@@ -567,43 +382,12 @@ public class DocumentService {
|
|||||||
.sorted(Comparator.comparingInt(
|
.sorted(Comparator.comparingInt(
|
||||||
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
|
||||||
.toList();
|
.toList();
|
||||||
return 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.
|
Sort springSort = resolveSort(sort, dir);
|
||||||
PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort, dir));
|
List<Document> results = documentRepository.findAll(spec, springSort);
|
||||||
Page<Document> page = documentRepository.findAll(spec, pageRequest);
|
return DocumentSearchResult.withMatchData(resolveDocumentTagColors(results), enrichWithMatchData(results, text));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Sort resolveSort(DocumentSort sort, String dir) {
|
private Sort resolveSort(DocumentSort sort, String dir) {
|
||||||
@@ -700,10 +484,6 @@ public class DocumentService {
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Document> getDocumentsByIds(List<UUID> ids) {
|
|
||||||
return documentRepository.findAllById(ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Document> getDocumentsWithoutVersions() {
|
public List<Document> getDocumentsWithoutVersions() {
|
||||||
return documentRepository.findDocumentsWithoutVersions();
|
return documentRepository.findDocumentsWithoutVersions();
|
||||||
}
|
}
|
||||||
@@ -733,7 +513,7 @@ public class DocumentService {
|
|||||||
PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
return documentRepository.findByMetadataCompleteFalse(pageable)
|
return documentRepository.findByMetadataCompleteFalse(pageable)
|
||||||
.stream()
|
.stream()
|
||||||
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle(), doc.getCreatedAt()))
|
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* Generates a presigned URL for downloading an object from S3/MinIO.
|
||||||
* Valid for 1 hour — covers multi-page documents on CPU-only OCR hardware
|
* Valid for 1 hour — covers multi-page documents on CPU-only OCR hardware
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ public class MassImportService {
|
|||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
private final S3Client s3Client;
|
private final S3Client s3Client;
|
||||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
|
||||||
|
|
||||||
@Value("${app.s3.bucket}")
|
@Value("${app.s3.bucket}")
|
||||||
private String bucketName;
|
private String bucketName;
|
||||||
@@ -333,10 +332,7 @@ public class MassImportService {
|
|||||||
if (tag != null) doc.getTags().add(tag);
|
if (tag != null) doc.getTags().add(tag);
|
||||||
doc.setMetadataComplete(metadataComplete);
|
doc.setMetadataComplete(metadataComplete);
|
||||||
|
|
||||||
Document saved = documentRepository.save(doc);
|
documentRepository.save(doc);
|
||||||
if (file.isPresent()) {
|
|
||||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
|
||||||
}
|
|
||||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,12 +109,8 @@ public class PersonService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person createPerson(PersonUpdateDTO dto) {
|
public Person createPerson(PersonUpdateDTO dto) {
|
||||||
if (dto.getPersonType() == PersonType.SKIP) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
|
||||||
}
|
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = Person.builder()
|
Person person = Person.builder()
|
||||||
.personType(dto.getPersonType())
|
|
||||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||||
.firstName(dto.getFirstName())
|
.firstName(dto.getFirstName())
|
||||||
.lastName(dto.getLastName())
|
.lastName(dto.getLastName())
|
||||||
@@ -140,13 +136,9 @@ public class PersonService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||||
if (dto.getPersonType() == PersonType.SKIP) {
|
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
|
||||||
}
|
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = personRepository.findById(id)
|
Person person = personRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||||
person.setPersonType(dto.getPersonType());
|
|
||||||
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
||||||
person.setFirstName(dto.getFirstName());
|
person.setFirstName(dto.getFirstName());
|
||||||
person.setLastName(dto.getLastName());
|
person.setLastName(dto.getLastName());
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
|
||||||
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
@@ -10,29 +8,38 @@ import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves the three Mission Control Strip queues (Segmentierung / Transkription / Lesefertig)
|
||||||
|
* and the weekly activity pulse used by the column headers.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class TranscriptionQueueService {
|
public class TranscriptionQueueService {
|
||||||
|
|
||||||
private static final int DEFAULT_QUEUE_SIZE = 5;
|
private static final int DEFAULT_QUEUE_SIZE = 5;
|
||||||
private static final int MAX_CONTRIBUTORS = 5;
|
|
||||||
|
|
||||||
private final DocumentRepository documentRepository;
|
private final DocumentRepository documentRepository;
|
||||||
private final AuditLogQueryService auditLogQueryService;
|
|
||||||
|
|
||||||
public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
|
public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
|
||||||
return enrichWithContributors(documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE));
|
return documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE)
|
||||||
|
.stream()
|
||||||
|
.map(this::toDTO)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
|
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
|
||||||
return enrichWithContributors(documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE));
|
return documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE)
|
||||||
|
.stream()
|
||||||
|
.map(this::toDTO)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
|
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
|
||||||
return enrichWithContributors(documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE));
|
return documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE)
|
||||||
|
.stream()
|
||||||
|
.map(this::toDTO)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
|
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
|
||||||
@@ -43,27 +50,14 @@ public class TranscriptionQueueService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<TranscriptionQueueItemDTO> enrichWithContributors(List<TranscriptionQueueProjection> projections) {
|
private TranscriptionQueueItemDTO toDTO(TranscriptionQueueProjection p) {
|
||||||
if (projections.isEmpty()) return List.of();
|
|
||||||
List<UUID> ids = projections.stream().map(TranscriptionQueueProjection::getId).toList();
|
|
||||||
Map<UUID, List<ActivityActorDTO>> contributorMap = auditLogQueryService.findContributorsPerDocument(ids);
|
|
||||||
return projections.stream()
|
|
||||||
.map(p -> toDTO(p, contributorMap.getOrDefault(p.getId(), List.of())))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private TranscriptionQueueItemDTO toDTO(TranscriptionQueueProjection p, List<ActivityActorDTO> allContributors) {
|
|
||||||
boolean hasMore = allContributors.size() > MAX_CONTRIBUTORS;
|
|
||||||
List<ActivityActorDTO> capped = hasMore ? allContributors.subList(0, MAX_CONTRIBUTORS) : allContributors;
|
|
||||||
return new TranscriptionQueueItemDTO(
|
return new TranscriptionQueueItemDTO(
|
||||||
p.getId(),
|
p.getId(),
|
||||||
p.getTitle(),
|
p.getTitle(),
|
||||||
p.getDocumentDate(),
|
p.getDocumentDate(),
|
||||||
p.getAnnotationCount(),
|
p.getAnnotationCount(),
|
||||||
p.getTextedBlockCount(),
|
p.getTextedBlockCount(),
|
||||||
p.getReviewedBlockCount(),
|
p.getReviewedBlockCount()
|
||||||
capped,
|
|
||||||
hasMore
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package org.raddatz.familienarchiv.service;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
|
||||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
@@ -23,13 +21,10 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static java.util.stream.Collectors.toSet;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -38,10 +33,9 @@ public class UserService {
|
|||||||
private final AppUserRepository userRepository;
|
private final AppUserRepository userRepository;
|
||||||
private final UserGroupRepository groupRepository;
|
private final UserGroupRepository groupRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AuditService auditService;
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser createUserOrUpdate(UUID actorId, CreateUserRequest request) {
|
public AppUser createUserOrUpdate(CreateUserRequest request) {
|
||||||
log.info("Creating or updating user: {}", request.getEmail());
|
log.info("Creating or updating user: {}", request.getEmail());
|
||||||
|
|
||||||
Set<UserGroup> groups = new HashSet<>();
|
Set<UserGroup> groups = new HashSet<>();
|
||||||
@@ -51,12 +45,10 @@ public class UserService {
|
|||||||
|
|
||||||
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
||||||
AppUser user;
|
AppUser user;
|
||||||
boolean isNew;
|
|
||||||
|
|
||||||
if (existingUser.isPresent()) {
|
if (existingUser.isPresent()) {
|
||||||
log.info("User exists, updating: {}", request.getEmail());
|
log.info("User exists, updating: {}", request.getEmail());
|
||||||
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
user = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
||||||
isNew = false;
|
|
||||||
} else {
|
} else {
|
||||||
log.info("Creating new user: {}", request.getEmail());
|
log.info("Creating new user: {}", request.getEmail());
|
||||||
user = AppUser.builder()
|
user = AppUser.builder()
|
||||||
@@ -69,42 +61,8 @@ public class UserService {
|
|||||||
.contact(request.getContact())
|
.contact(request.getContact())
|
||||||
.enabled(true)
|
.enabled(true)
|
||||||
.build();
|
.build();
|
||||||
isNew = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AppUser saved = userRepository.save(user);
|
|
||||||
if (isNew) {
|
|
||||||
auditService.logAfterCommit(AuditKind.USER_CREATED, actorId, null,
|
|
||||||
Map.of("userId", saved.getId().toString(), "email", saved.getEmail()));
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public AppUser createUserForBootstrap(CreateUserRequest request) {
|
|
||||||
log.info("Bootstrap user creation (no audit): {}", request.getEmail());
|
|
||||||
|
|
||||||
Set<UserGroup> groups = new HashSet<>();
|
|
||||||
if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) {
|
|
||||||
groups.addAll(groupRepository.findAllById(request.getGroupIds()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional<AppUser> existingUser = userRepository.findByEmail(request.getEmail());
|
|
||||||
if (existingUser.isPresent()) {
|
|
||||||
AppUser updated = existingUser.get().updateFromRequest(request, passwordEncoder, groups);
|
|
||||||
return userRepository.save(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
AppUser user = AppUser.builder()
|
|
||||||
.email(request.getEmail())
|
|
||||||
.password(passwordEncoder.encode(request.getInitialPassword()))
|
|
||||||
.groups(groups)
|
|
||||||
.firstName(request.getFirstName())
|
|
||||||
.lastName(request.getLastName())
|
|
||||||
.birthDate(request.getBirthDate())
|
|
||||||
.contact(request.getContact())
|
|
||||||
.enabled(true)
|
|
||||||
.build();
|
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,13 +94,10 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteUser(UUID actorId, UUID userId) {
|
public void deleteUser(UUID userId) {
|
||||||
AppUser user = userRepository.findById(userId)
|
AppUser user = userRepository.findById(userId)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + userId));
|
||||||
String email = user.getEmail();
|
|
||||||
userRepository.delete(user);
|
userRepository.delete(user);
|
||||||
auditService.logAfterCommit(AuditKind.USER_DELETED, actorId, null,
|
|
||||||
Map.of("userId", userId.toString(), "email", email));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AppUser getById(UUID id) {
|
public AppUser getById(UUID id) {
|
||||||
@@ -186,7 +141,7 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser adminUpdateUser(UUID actorId, UUID id, AdminUpdateUserRequest dto) {
|
public AppUser adminUpdateUser(UUID id, AdminUpdateUserRequest dto) {
|
||||||
AppUser user = getById(id);
|
AppUser user = getById(id);
|
||||||
|
|
||||||
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
if (dto.getEmail() != null && !dto.getEmail().isBlank()) {
|
||||||
@@ -211,27 +166,13 @@ public class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.getGroupIds() != null) {
|
if (dto.getGroupIds() != null) {
|
||||||
Set<UserGroup> before = new HashSet<>(user.getGroups());
|
Set<UserGroup> groups = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
||||||
Set<UserGroup> after = new HashSet<>(groupRepository.findAllById(dto.getGroupIds()));
|
user.setGroups(groups);
|
||||||
user.setGroups(after);
|
|
||||||
groupChangePayload(before, after, id, user.getEmail())
|
|
||||||
.ifPresent(payload -> auditService.logAfterCommit(AuditKind.GROUP_MEMBERSHIP_CHANGED, actorId, null, payload));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<Map<String, Object>> groupChangePayload(
|
|
||||||
Set<UserGroup> before, Set<UserGroup> after, UUID userId, String email) {
|
|
||||||
Set<UUID> beforeIds = before.stream().map(UserGroup::getId).collect(toSet());
|
|
||||||
Set<UUID> afterIds = after.stream().map(UserGroup::getId).collect(toSet());
|
|
||||||
if (beforeIds.equals(afterIds)) return Optional.empty();
|
|
||||||
List<String> added = after.stream().filter(g -> !beforeIds.contains(g.getId())).map(UserGroup::getName).toList();
|
|
||||||
List<String> removed = before.stream().filter(g -> !afterIds.contains(g.getId())).map(UserGroup::getName).toList();
|
|
||||||
return Optional.of(Map.of("userId", userId.toString(), "email", email,
|
|
||||||
"addedGroups", added, "removedGroups", removed));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
public void changePassword(UUID userId, ChangePasswordDTO dto) {
|
||||||
AppUser user = getById(userId);
|
AppUser user = getById(userId);
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ spring:
|
|||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 50MB
|
max-file-size: 50MB
|
||||||
max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317
|
max-request-size: 50MB
|
||||||
file-size-threshold: 2KB
|
|
||||||
|
|
||||||
mail:
|
mail:
|
||||||
host: ${MAIL_HOST:}
|
host: ${MAIL_HOST:}
|
||||||
|
|||||||
@@ -19,7 +19,4 @@ CREATE INDEX idx_audit_log_kind ON audit_log (kind);
|
|||||||
|
|
||||||
-- Enforce append-only at the database layer: the application role may INSERT
|
-- Enforce append-only at the database layer: the application role may INSERT
|
||||||
-- but must not UPDATE or DELETE audit rows.
|
-- but must not UPDATE or DELETE audit rows.
|
||||||
-- NOTE: This REVOKE is a no-op when the current user is the table owner.
|
|
||||||
-- PostgreSQL owners retain all privileges regardless of REVOKE. The append-only
|
|
||||||
-- guarantee is enforced at the application layer only.
|
|
||||||
REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER;
|
REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER;
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
CREATE INDEX IF NOT EXISTS idx_transcription_blocks_document_reviewed
|
|
||||||
ON transcription_blocks (document_id, reviewed);
|
|
||||||
@@ -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');
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE documents
|
|
||||||
ADD COLUMN thumbnail_key VARCHAR(255),
|
|
||||||
ADD COLUMN thumbnail_generated_at TIMESTAMP;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.audit;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.test.annotation.DirtiesContext;
|
|
||||||
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 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)
|
|
||||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
|
||||||
class AuditServiceIntegrationTest {
|
|
||||||
|
|
||||||
@MockitoBean S3Client s3Client;
|
|
||||||
@Autowired AuditService auditService;
|
|
||||||
@Autowired AuditLogRepository auditLogRepository;
|
|
||||||
@Autowired TransactionTemplate transactionTemplate;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void logAfterCommit_writes_ANNOTATION_CREATED_row_after_transaction_commits() {
|
|
||||||
transactionTemplate.execute(status -> {
|
|
||||||
auditService.logAfterCommit(AuditKind.ANNOTATION_CREATED, null, null, null);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
await().atMost(5, SECONDS).until(() -> auditLogRepository.count() > 0);
|
|
||||||
assertThat(auditLogRepository.findAll())
|
|
||||||
.extracting(AuditLog::getKind)
|
|
||||||
.containsExactly(AuditKind.ANNOTATION_CREATED);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void logAfterCommit_writes_no_row_when_transaction_rolls_back() {
|
|
||||||
try {
|
|
||||||
transactionTemplate.execute(status -> {
|
|
||||||
auditService.logAfterCommit(AuditKind.ANNOTATION_CREATED, null, null, null);
|
|
||||||
throw new RuntimeException("force rollback");
|
|
||||||
});
|
|
||||||
} catch (RuntimeException ignored) {}
|
|
||||||
|
|
||||||
assertThat(auditLogRepository.count()).isZero();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.core.task.TaskExecutor;
|
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ import static org.mockito.Mockito.*;
|
|||||||
class AuditServiceTest {
|
class AuditServiceTest {
|
||||||
|
|
||||||
@Mock AuditLogRepository auditLogRepository;
|
@Mock AuditLogRepository auditLogRepository;
|
||||||
@Mock TaskExecutor auditExecutor;
|
|
||||||
@InjectMocks AuditService auditService;
|
@InjectMocks AuditService auditService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -96,7 +94,9 @@ class AuditServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void logAfterCommit_registersCallback_andSubmitsToExecutor_afterCommit() {
|
void logAfterCommit_registersCallback_andSavesOnlyAfterCommit_whenTransactionIsActive() {
|
||||||
|
when(auditLogRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
try (MockedStatic<TransactionSynchronizationManager> mocked =
|
try (MockedStatic<TransactionSynchronizationManager> mocked =
|
||||||
mockStatic(TransactionSynchronizationManager.class)) {
|
mockStatic(TransactionSynchronizationManager.class)) {
|
||||||
mocked.when(TransactionSynchronizationManager::isActualTransactionActive).thenReturn(true);
|
mocked.when(TransactionSynchronizationManager::isActualTransactionActive).thenReturn(true);
|
||||||
@@ -106,16 +106,15 @@ class AuditServiceTest {
|
|||||||
|
|
||||||
auditService.logAfterCommit(AuditKind.TEXT_SAVED, null, null, null);
|
auditService.logAfterCommit(AuditKind.TEXT_SAVED, null, null, null);
|
||||||
|
|
||||||
// Callback registered but executor not yet invoked
|
// Callback registered but repo not yet called
|
||||||
assertThat(captured).hasSize(1);
|
assertThat(captured).hasSize(1);
|
||||||
verify(auditExecutor, never()).execute(any());
|
verify(auditLogRepository, never()).save(any());
|
||||||
|
|
||||||
// Simulate transaction commit
|
// Simulate transaction commit
|
||||||
captured.get(0).afterCommit();
|
captured.get(0).afterCommit();
|
||||||
|
|
||||||
// Write submitted to executor — not called inline
|
// Now the row should be saved
|
||||||
verify(auditExecutor).execute(any());
|
verify(auditLogRepository).save(any());
|
||||||
verify(auditLogRepository, never()).save(any());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
|||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
import org.raddatz.familienarchiv.service.MassImportService;
|
import org.raddatz.familienarchiv.service.MassImportService;
|
||||||
import org.raddatz.familienarchiv.service.ThumbnailBackfillService;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
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.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
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.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
@@ -37,7 +33,6 @@ class AdminControllerTest {
|
|||||||
@MockitoBean MassImportService massImportService;
|
@MockitoBean MassImportService massImportService;
|
||||||
@MockitoBean DocumentService documentService;
|
@MockitoBean DocumentService documentService;
|
||||||
@MockitoBean DocumentVersionService documentVersionService;
|
@MockitoBean DocumentVersionService documentVersionService;
|
||||||
@MockitoBean ThumbnailBackfillService thumbnailBackfillService;
|
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -88,57 +83,4 @@ class AdminControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.count").value(3));
|
.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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,246 @@ class CommentControllerTest {
|
|||||||
|
|
||||||
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
|
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
|
||||||
private static final UUID DOC_ID = UUID.randomUUID();
|
private static final UUID DOC_ID = UUID.randomUUID();
|
||||||
|
private static final UUID ANN_ID = UUID.randomUUID();
|
||||||
private static final UUID COMMENT_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 ─────────────────────────────────────────────
|
// ─── Block comment endpoints ─────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -67,138 +305,4 @@ class CommentControllerTest {
|
|||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
.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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
@@ -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.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
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.time.LocalDateTime;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
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.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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -71,7 +66,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_returns200_whenAuthenticated() throws Exception {
|
void search_returns200_whenAuthenticated() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
@@ -81,13 +76,13 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_withStatusParam_passesItToService() throws Exception {
|
void search_withStatusParam_passesItToService() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any());
|
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -114,18 +109,18 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_responseContainsTotalCount() throws Exception {
|
void search_responseContainsTotalCount() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.totalElements").value(0))
|
.andExpect(jsonPath("$.total").value(0))
|
||||||
.andExpect(jsonPath("$.items").isArray());
|
.andExpect(jsonPath("$.documents").isArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_responseBodyItemsContainMatchData() throws Exception {
|
void search_responseBodyContainsMatchDataKey() throws Exception {
|
||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
Document doc = Document.builder()
|
Document doc = Document.builder()
|
||||||
.id(docId)
|
.id(docId)
|
||||||
@@ -133,82 +128,18 @@ class DocumentControllerTest {
|
|||||||
.originalFilename("brief.pdf")
|
.originalFilename("brief.pdf")
|
||||||
.status(DocumentStatus.UPLOADED)
|
.status(DocumentStatus.UPLOADED)
|
||||||
.build();
|
.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());
|
"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()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
.thenReturn(DocumentSearchResult.withMatchData(List.of(doc), Map.of(docId, matchData)));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.items").isArray())
|
.andExpect(jsonPath("$.matchData").isMap())
|
||||||
.andExpect(jsonPath("$.items[0].matchData.transcriptionSnippet")
|
.andExpect(jsonPath("$.matchData." + docId + ".transcriptionSnippet")
|
||||||
.value("Er schrieb einen langen Brief"));
|
.value("Er schrieb einen langen Brief"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── /api/documents/search pagination ─────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void search_responseExposesPagingFields() throws Exception {
|
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.pageNumber").exists())
|
|
||||||
.andExpect(jsonPath("$.pageSize").exists())
|
|
||||||
.andExpect(jsonPath("$.totalPages").exists())
|
|
||||||
.andExpect(jsonPath("$.totalElements").exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void search_returns400_whenSizeExceedsMax() throws Exception {
|
|
||||||
// Locks @Validated on the controller — removing it silently reopens the
|
|
||||||
// DoS window where a client could request all 1500 docs + enrichment.
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("size", "101"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void search_returns400_whenSizeBelowMin() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("size", "0"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void search_returns400_whenPageNegative() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("page", "-1"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void search_returns400_whenPageAboveMax() throws Exception {
|
|
||||||
// Guards against page * size overflow into negative SQL OFFSET
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("page", "200000"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void search_passesPageRequestToService() throws Exception {
|
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
|
|
||||||
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
|
|
||||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture());
|
|
||||||
org.springframework.data.domain.Pageable pageable = captor.getValue();
|
|
||||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
|
|
||||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -428,62 +359,6 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isNotFound());
|
.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 ─────────────────
|
// ─── POST /api/documents/quick-upload — null/empty files ─────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -505,7 +380,7 @@ class DocumentControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser
|
||||||
void getIncompleteCount_returns200_withCount() throws Exception {
|
void getIncompleteCount_returns200_withCount() throws Exception {
|
||||||
when(documentService.getIncompleteCount()).thenReturn(3L);
|
when(documentService.getIncompleteCount()).thenReturn(3L);
|
||||||
|
|
||||||
@@ -514,52 +389,14 @@ class DocumentControllerTest {
|
|||||||
.andExpect(jsonPath("$.count").value(3));
|
.andExpect(jsonPath("$.count").value(3));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
// ─── GET /api/documents/incomplete (removed — superseded by dashboard) ────
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void getIncompleteCount_returns403_forReaderOnly() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/documents/incomplete-count"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GET /api/documents/incomplete ───────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
@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"))
|
mockMvc.perform(get("/api/documents/incomplete"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().is4xxClientError());
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
||||||
@@ -572,7 +409,7 @@ class DocumentControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser
|
||||||
void getNextIncomplete_returns200_whenNextExists() throws Exception {
|
void getNextIncomplete_returns200_whenNextExists() throws Exception {
|
||||||
UUID excludeId = UUID.randomUUID();
|
UUID excludeId = UUID.randomUUID();
|
||||||
Document next = Document.builder()
|
Document next = Document.builder()
|
||||||
@@ -586,15 +423,7 @@ class DocumentControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser
|
||||||
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")
|
|
||||||
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
|
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
|
||||||
UUID excludeId = UUID.randomUUID();
|
UUID excludeId = UUID.randomUUID();
|
||||||
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
|
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
|
||||||
@@ -768,476 +597,4 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.editorName").value("Otto"));
|
.andExpect(jsonPath("$.editorName").value("Otto"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── POST /api/documents/quick-upload — metadata part ────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception {
|
|
||||||
UUID senderId = UUID.randomUUID();
|
|
||||||
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
|
||||||
|
|
||||||
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build();
|
|
||||||
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build();
|
|
||||||
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build();
|
|
||||||
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
|
||||||
.thenReturn(new DocumentService.StoreResult(doc1, true));
|
|
||||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
|
||||||
.thenReturn(new DocumentService.StoreResult(doc2, true));
|
|
||||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
|
||||||
.thenReturn(new DocumentService.StoreResult(doc3, true));
|
|
||||||
|
|
||||||
org.springframework.mock.web.MockMultipartFile f1 =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
|
||||||
org.springframework.mock.web.MockMultipartFile f2 =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
|
||||||
org.springframework.mock.web.MockMultipartFile f3 =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
|
||||||
org.springframework.mock.web.MockMultipartFile metadata =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
|
||||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.created.length()").value(3))
|
|
||||||
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
|
||||||
.andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString()))
|
|
||||||
.andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString()))
|
|
||||||
.andExpect(jsonPath("$.updated").isEmpty())
|
|
||||||
.andExpect(jsonPath("$.errors").isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception {
|
|
||||||
UUID senderId = UUID.randomUUID();
|
|
||||||
Person sender = Person.builder().id(senderId).lastName("Müller").build();
|
|
||||||
Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build();
|
|
||||||
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
|
||||||
.thenReturn(new DocumentService.StoreResult(existing, false));
|
|
||||||
|
|
||||||
org.springframework.mock.web.MockMultipartFile file =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1});
|
|
||||||
org.springframework.mock.web.MockMultipartFile metadata =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
|
||||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.created").isEmpty())
|
|
||||||
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
|
||||||
.andExpect(jsonPath("$.errors").isEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception {
|
|
||||||
Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
|
||||||
Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
|
||||||
Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build();
|
|
||||||
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
|
|
||||||
.thenReturn(new DocumentService.StoreResult(docA, true));
|
|
||||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
|
|
||||||
.thenReturn(new DocumentService.StoreResult(docB, true));
|
|
||||||
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
|
|
||||||
.thenReturn(new DocumentService.StoreResult(docC, true));
|
|
||||||
|
|
||||||
org.springframework.mock.web.MockMultipartFile f1 =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
|
||||||
org.springframework.mock.web.MockMultipartFile f2 =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
|
||||||
org.springframework.mock.web.MockMultipartFile f3 =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
|
|
||||||
org.springframework.mock.web.MockMultipartFile metadata =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
|
||||||
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
|
||||||
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
|
||||||
.andExpect(jsonPath("$.created[2].title").value("Gamma"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
org.mockito.Mockito.doThrow(
|
|
||||||
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
|
||||||
org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
|
|
||||||
.when(documentService).validateBatch(eq(2), any());
|
|
||||||
|
|
||||||
org.springframework.mock.web.MockMultipartFile f1 =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
|
|
||||||
org.springframework.mock.web.MockMultipartFile f2 =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
|
|
||||||
org.springframework.mock.web.MockMultipartFile metadata =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
|
||||||
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception {
|
|
||||||
Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build();
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
|
|
||||||
org.mockito.ArgumentCaptor<DocumentBatchMetadataDTO> captor =
|
|
||||||
org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class);
|
|
||||||
when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any()))
|
|
||||||
.thenReturn(new DocumentService.StoreResult(doc, true));
|
|
||||||
|
|
||||||
org.springframework.mock.web.MockMultipartFile file =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1});
|
|
||||||
org.springframework.mock.web.MockMultipartFile metadata =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
|
||||||
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
|
||||||
.containsExactly("Briefwechsel", "Krieg");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
org.mockito.Mockito.doThrow(
|
|
||||||
org.raddatz.familienarchiv.exception.DomainException.badRequest(
|
|
||||||
org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
|
|
||||||
.when(documentService).validateBatch(eq(51), any());
|
|
||||||
|
|
||||||
var builder = multipart("/api/documents/quick-upload");
|
|
||||||
for (int i = 0; i < 51; i++) {
|
|
||||||
builder.file(new org.springframework.mock.web.MockMultipartFile(
|
|
||||||
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
|
||||||
}
|
|
||||||
|
|
||||||
mockMvc.perform(builder)
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PATCH /api/documents/bulk ───────────────────────────────────────────
|
|
||||||
|
|
||||||
private static String bulkBody(String... uuids) {
|
|
||||||
StringBuilder sb = new StringBuilder("{\"documentIds\":[");
|
|
||||||
for (int i = 0; i < uuids.length; i++) {
|
|
||||||
if (i > 0) sb.append(",");
|
|
||||||
sb.append("\"").append(uuids[i]).append("\"");
|
|
||||||
}
|
|
||||||
sb.append("]}");
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(bulkBody(UUID.randomUUID().toString())))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void patchBulk_returns403_forReadAllUser() throws Exception {
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(bulkBody(UUID.randomUUID().toString())))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"documentIds\":[]}"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{}"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void patchBulk_returns400_whenDocumentIdsExceedsCap() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
|
|
||||||
String[] ids = new String[501];
|
|
||||||
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(bulkBody(ids)))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void patchBulk_returns400_whenArchiveBoxExceeds255Chars() throws Exception {
|
|
||||||
// Tobias C2 — DocumentBulkEditDTO.archiveBox carries @Size(max=255).
|
|
||||||
// Without @Valid on @RequestBody this would silently land an
|
|
||||||
// arbitrarily long string; the test pins both the annotation and
|
|
||||||
// the controller-level @Valid wiring.
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
String tooLong = "x".repeat(256);
|
|
||||||
|
|
||||||
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(body))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void patchBulk_acceptsExactly500Ids_atTheCap() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
|
||||||
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
|
||||||
|
|
||||||
String[] ids = new String[500];
|
|
||||||
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
|
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(bulkBody(ids)))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.updated").value(500));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(documentService.applyBulkEditToDocument(eq(id), any(), any()))
|
|
||||||
.thenAnswer(inv -> Document.builder().id(id).build());
|
|
||||||
|
|
||||||
// Same id sent three times — controller should dedupe and call the
|
|
||||||
// service exactly once, returning updated=1, not 3.
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(bulkBody(id.toString(), id.toString(), id.toString())))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.updated").value(1));
|
|
||||||
|
|
||||||
verify(documentService, org.mockito.Mockito.times(1))
|
|
||||||
.applyBulkEditToDocument(eq(id), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void patchBulk_returns200_andCallsServiceForEachId() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
UUID id1 = UUID.randomUUID();
|
|
||||||
UUID id2 = UUID.randomUUID();
|
|
||||||
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
|
||||||
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(bulkBody(id1.toString(), id2.toString())))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.updated").value(2))
|
|
||||||
.andExpect(jsonPath("$.errors").isEmpty());
|
|
||||||
|
|
||||||
verify(documentService).applyBulkEditToDocument(eq(id1), any(), any());
|
|
||||||
verify(documentService).applyBulkEditToDocument(eq(id2), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GET /api/documents/ids ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getDocumentIds_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/documents/ids"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void getDocumentIds_returns403_forUserWithoutWriteAll() throws Exception {
|
|
||||||
// /ids is gated WRITE_ALL because it powers the bulk-edit "Alle X
|
|
||||||
// editieren" fast path; no other consumer needs it.
|
|
||||||
mockMvc.perform(get("/api/documents/ids"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
|
||||||
.thenReturn(List.of(id));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/ids"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$[0]").value(id.toString()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void getDocumentIds_passesSenderIdParamToService() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
UUID senderId = UUID.randomUUID();
|
|
||||||
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()))
|
|
||||||
.thenReturn(List.of());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void getDocumentIds_returns400_whenResultExceedsFilterCap() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
|
|
||||||
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
|
|
||||||
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
|
|
||||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
|
||||||
.thenReturn(tooMany);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/ids"))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── POST /api/documents/batch-metadata ──────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
|
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"ids\":[]}"))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void batchMetadata_returns400_whenIdsExceedsCap() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
StringBuilder sb = new StringBuilder("{\"ids\":[");
|
|
||||||
for (int i = 0; i < 501; i++) {
|
|
||||||
if (i > 0) sb.append(",");
|
|
||||||
sb.append("\"").append(UUID.randomUUID()).append("\"");
|
|
||||||
}
|
|
||||||
sb.append("]}");
|
|
||||||
|
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(sb.toString()))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void batchMetadata_returnsSummaries_forExistingIds() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(documentService.batchMetadata(any())).thenReturn(List.of(
|
|
||||||
new org.raddatz.familienarchiv.dto.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file")));
|
|
||||||
|
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"ids\":[\"" + id + "\"]}"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
|
||||||
.andExpect(jsonPath("$[0].title").value("Brief"))
|
|
||||||
.andExpect(jsonPath("$[0].pdfUrl").value("/api/documents/" + id + "/file"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages() throws Exception {
|
|
||||||
// Nora C4 — DocumentController.sanitizeForLog defends against
|
|
||||||
// CWE-117 (log injection) by replacing CR/LF in any free-form string
|
|
||||||
// it interpolates. Same helper now sanitises BulkEditError.message
|
|
||||||
// before it round-trips to the frontend.
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
UUID badId = UUID.randomUUID();
|
|
||||||
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
|
|
||||||
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
|
||||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
|
|
||||||
"evil\r\nFAKE LOG ENTRY: admin logged in"));
|
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(bulkBody(badId.toString())))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.errors[0].message",
|
|
||||||
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\n"))))
|
|
||||||
.andExpect(jsonPath("$.errors[0].message",
|
|
||||||
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\r"))))
|
|
||||||
.andExpect(jsonPath("$.errors[0].message",
|
|
||||||
org.hamcrest.Matchers.containsString("evil_")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
|
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
|
||||||
UUID okId = UUID.randomUUID();
|
|
||||||
UUID badId = UUID.randomUUID();
|
|
||||||
when(documentService.applyBulkEditToDocument(eq(okId), any(), any()))
|
|
||||||
.thenAnswer(inv -> Document.builder().id(okId).build());
|
|
||||||
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
|
|
||||||
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
|
||||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(bulkBody(okId.toString(), badId.toString())))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.updated").value(1))
|
|
||||||
.andExpect(jsonPath("$.errors[0].id").value(badId.toString()))
|
|
||||||
.andExpect(jsonPath("$.errors[0].message").value(
|
|
||||||
org.hamcrest.Matchers.containsString("not found")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||||
@@ -28,7 +25,6 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -187,19 +183,19 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +204,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +213,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,53 +225,11 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.firstName").value("Hans"));
|
.andExpect(jsonPath("$.firstName").value("Hans"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void createPerson_returns200_forInstitution_withoutFirstName() throws Exception {
|
|
||||||
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
|
||||||
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.lastName").value("Verlag GmbH"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void createPerson_trimsTitle_beforePersisting() throws Exception {
|
|
||||||
ArgumentCaptor<org.raddatz.familienarchiv.dto.PersonUpdateDTO> captor =
|
|
||||||
ArgumentCaptor.forClass(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class);
|
|
||||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
|
||||||
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
|
|
||||||
assertThat(captor.getValue().getTitle()).isEqualTo("Prof.");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void createPerson_returns400_whenPersonTypeIsSkip() throws Exception {
|
|
||||||
when(personService.createPerson(any())).thenThrow(
|
|
||||||
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(jsonPath("$.code").value("INVALID_PERSON_TYPE"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -288,10 +242,10 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@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())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +254,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +267,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.lastName").value("Müller"));
|
.andExpect(jsonPath("$.lastName").value("Müller"));
|
||||||
}
|
}
|
||||||
@@ -363,10 +317,11 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
|
// firstName valid, lastName blank → second || operand = true → 400
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +339,7 @@ class PersonControllerTest {
|
|||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||||
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
|
"\"notes\":\"Some notes\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.firstName").value("Maria"))
|
.andExpect(jsonPath("$.firstName").value("Maria"))
|
||||||
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
||||||
@@ -400,7 +355,7 @@ class PersonControllerTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,7 +366,7 @@ class PersonControllerTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +377,7 @@ class PersonControllerTest {
|
|||||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,7 +386,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import org.springframework.security.test.context.support.WithMockUser;
|
|||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -43,9 +41,7 @@ class TranscriptionQueueControllerTest {
|
|||||||
UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
UUID.fromString("00000000-0000-0000-0000-000000000001"),
|
||||||
"Testbrief",
|
"Testbrief",
|
||||||
LocalDate.of(1920, 6, 15),
|
LocalDate.of(1920, 6, 15),
|
||||||
3, 1, 0,
|
3, 1, 0
|
||||||
List.of(new ActivityActorDTO("TR", "#a6dad8", "Test Raddatz")),
|
|
||||||
false
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private static final TranscriptionWeeklyStatsDTO STATS = new TranscriptionWeeklyStatsDTO(2L, 5L);
|
private static final TranscriptionWeeklyStatsDTO STATS = new TranscriptionWeeklyStatsDTO(2L, 5L);
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -106,55 +104,4 @@ class UserControllerTest {
|
|||||||
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
.content("{\"email\":\"\",\"initialPassword\":\"secret123\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── permission enforcement ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "reader@example.com")
|
|
||||||
void createUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/users")
|
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "reader@example.com")
|
|
||||||
void adminUpdateUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
|
||||||
.content("{}"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(username = "reader@example.com")
|
|
||||||
void deleteUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
|
||||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── unauthenticated access ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUser_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(post("/api/users")
|
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
|
||||||
.content("{\"email\":\"x@x.com\",\"initialPassword\":\"secret123\"}"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminUpdateUser_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/users/" + UUID.randomUUID())
|
|
||||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
|
||||||
.content("{}"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deleteUser_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(delete("/api/users/" + UUID.randomUUID()))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityFeedRow;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryRepository;
|
|
||||||
import org.raddatz.familienarchiv.audit.ContributorRow;
|
|
||||||
import org.raddatz.familienarchiv.audit.PulseStatsRow;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
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.time.OffsetDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
@DataJpaTest
|
|
||||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
|
||||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
|
||||||
class AuditLogQueryRepositoryIntegrationTest {
|
|
||||||
|
|
||||||
static final UUID USER_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
|
||||||
static final UUID DOC_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
|
||||||
|
|
||||||
@Autowired AuditLogQueryRepository auditLogQueryRepository;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Sql(statements = {
|
|
||||||
"INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')",
|
|
||||||
"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) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
|
|
||||||
})
|
|
||||||
void findMostRecentDocumentIdByActor_returns_document_with_annotation_only_events() {
|
|
||||||
Optional<UUID> result = auditLogQueryRepository.findMostRecentDocumentIdByActor(USER_ID);
|
|
||||||
|
|
||||||
assertThat(result).contains(DOC_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Sql(statements = {
|
|
||||||
"INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')",
|
|
||||||
"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"));
|
|
||||||
|
|
||||||
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
|
|
||||||
@Sql(statements = {
|
|
||||||
"INSERT INTO users (id, enabled, email, password) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw')",
|
|
||||||
"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}')",
|
|
||||||
"INSERT INTO audit_log (kind, actor_id, document_id, payload) VALUES ('TEXT_SAVED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '{\"blockId\":\"ccc\",\"pageNumber\":1}')",
|
|
||||||
"INSERT INTO audit_log (kind, document_id) VALUES ('FILE_UPLOADED', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
|
|
||||||
})
|
|
||||||
void getPulseStats_countsAnnotationsTranscriptionsAndUploads() {
|
|
||||||
OffsetDateTime weekStart = OffsetDateTime.now(ZoneOffset.UTC).minusDays(7);
|
|
||||||
|
|
||||||
PulseStatsRow stats = auditLogQueryRepository.getPulseStats(weekStart, USER_ID.toString());
|
|
||||||
|
|
||||||
assertThat(stats.getAnnotated()).isEqualTo(1);
|
|
||||||
assertThat(stats.getTranscribed()).isEqualTo(1);
|
|
||||||
assertThat(stats.getUploaded()).isEqualTo(1);
|
|
||||||
assertThat(stats.getYourPages()).isGreaterThanOrEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Sql(statements = {
|
|
||||||
"INSERT INTO users (id, enabled, email, password, first_name, last_name, color) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', true, 'testuser@test.com', 'pw', 'Anna', 'Meier', '#f00')",
|
|
||||||
"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) VALUES ('ANNOTATION_CREATED', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')"
|
|
||||||
})
|
|
||||||
void findContributorsPerDocument_returnsContributorWithInitialsAndColor() {
|
|
||||||
List<ContributorRow> rows = auditLogQueryRepository.findContributorsPerDocument(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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
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;
|
|
||||||
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.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.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;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
||||||
|
|
||||||
@WebMvcTest(DashboardController.class)
|
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
|
||||||
class DashboardControllerTest {
|
|
||||||
|
|
||||||
@Autowired MockMvc mockMvc;
|
|
||||||
|
|
||||||
@MockitoBean DashboardService dashboardService;
|
|
||||||
@MockitoBean UserService userService;
|
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
|
||||||
|
|
||||||
// ─── Security ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void resume_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/dashboard/resume"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void resume_returns403_whenUserHasNoPermissions() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/dashboard/resume"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void pulse_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/dashboard/pulse"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void pulse_returns403_whenUserHasNoPermissions() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/dashboard/pulse"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GET /api/dashboard/resume ────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void resume_returnsNull_whenNoInProgressTranscription() throws Exception {
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
when(userService.findByEmail(any())).thenReturn(
|
|
||||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
|
||||||
when(dashboardService.getResume(userId)).thenReturn(null);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/dashboard/resume"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$").doesNotExist());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void resume_returnsDocument_whenUserHasInProgressTranscription() throws Exception {
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
when(userService.findByEmail(any())).thenReturn(
|
|
||||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
|
||||||
|
|
||||||
DashboardResumeDTO resume = new DashboardResumeDTO(docId, "Brief an Frieda",
|
|
||||||
"Frieda an Wilhelm · 1923", "Liebe Frieda…", 4, 38, null, List.of());
|
|
||||||
when(dashboardService.getResume(userId)).thenReturn(resume);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/dashboard/resume"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.documentId").value(docId.toString()))
|
|
||||||
.andExpect(jsonPath("$.pct").value(38));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GET /api/dashboard/pulse ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void pulse_returnsWeekStats() throws Exception {
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
when(userService.findByEmail(any())).thenReturn(
|
|
||||||
AppUser.builder().id(userId).email("u@test.com").password("pw").build());
|
|
||||||
|
|
||||||
DashboardPulseDTO pulse = new DashboardPulseDTO(86, 23, 9, 47, 4, List.of());
|
|
||||||
when(dashboardService.getPulse(userId)).thenReturn(pulse);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/dashboard/pulse"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.pages").value(86))
|
|
||||||
.andExpect(jsonPath("$.annotated").value(23));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void activity_returns401_whenUnauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/dashboard/activity"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser
|
|
||||||
void activity_returns403_whenUserHasNoPermissions() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/dashboard/activity"))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── GET /api/dashboard/activity ─────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
|
||||||
void activity_returnsDeduplicated_feed() 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())
|
|
||||||
.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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.dashboard;
|
|
||||||
|
|
||||||
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.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;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class DashboardServiceTest {
|
|
||||||
|
|
||||||
@Mock AuditLogQueryService auditLogQueryService;
|
|
||||||
@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
|
|
||||||
void getResume_collaboratorName_isNullSafe_whenFirstNameIsNull() {
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID collaboratorId = UUID.randomUUID();
|
|
||||||
|
|
||||||
Document doc = Document.builder()
|
|
||||||
.id(docId).title("Brief").originalFilename("brief.pdf")
|
|
||||||
.receivers(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
TranscriptionBlock block = TranscriptionBlock.builder()
|
|
||||||
.id(UUID.randomUUID()).annotationId(UUID.randomUUID())
|
|
||||||
.documentId(docId).sortOrder(1).updatedBy(collaboratorId)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
AppUser collaborator = AppUser.builder()
|
|
||||||
.id(collaboratorId).email("s@test.com").password("pw")
|
|
||||||
.firstName(null).lastName("Schmidt").color("#abc")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId));
|
|
||||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
|
||||||
when(transcriptionService.listBlocks(docId)).thenReturn(List.of(block));
|
|
||||||
when(userService.getById(collaboratorId)).thenReturn(collaborator);
|
|
||||||
|
|
||||||
DashboardResumeDTO result = dashboardService.getResume(userId);
|
|
||||||
|
|
||||||
assertThat(result).isNotNull();
|
|
||||||
assertThat(result.collaborators()).hasSize(1);
|
|
||||||
assertThat(result.collaborators().get(0).name()).isEqualTo("Schmidt");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── getActivity bulk-load ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getActivity_loadsDocumentTitles_withoutPerRowLookup() {
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
|
|
||||||
ActivityFeedRow row = mockFeedRow(docId, "ANNOTATION_CREATED");
|
|
||||||
when(auditLogQueryService.findActivityFeed(userId, 5, AuditKind.ROLLUP_ELIGIBLE)).thenReturn(List.of(row, row));
|
|
||||||
|
|
||||||
Document doc = Document.builder()
|
|
||||||
.id(docId).title("Familienbrief").originalFilename("f.pdf")
|
|
||||||
.receivers(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
when(documentService.getDocumentsByIds(List.of(docId))).thenReturn(List.of(doc));
|
|
||||||
|
|
||||||
List<ActivityFeedItemDTO> items = dashboardService.getActivity(userId, 5, AuditKind.ROLLUP_ELIGIBLE);
|
|
||||||
|
|
||||||
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; }
|
|
||||||
public String getActorInitials() { return ""; }
|
|
||||||
public String getActorColor() { return ""; }
|
|
||||||
public String getActorName() { return ""; }
|
|
||||||
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; }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,103 +2,67 @@ package org.raddatz.familienarchiv.dto;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class DocumentSearchResultTest {
|
class DocumentSearchResultTest {
|
||||||
|
|
||||||
private DocumentSearchItem item(UUID docId) {
|
private Document doc(UUID id) {
|
||||||
Document doc = Document.builder()
|
return Document.builder()
|
||||||
.id(docId)
|
.id(id)
|
||||||
.title("Test")
|
.title("Test")
|
||||||
.originalFilename("test.pdf")
|
.originalFilename("test.pdf")
|
||||||
.status(DocumentStatus.UPLOADED)
|
.status(DocumentStatus.UPLOADED)
|
||||||
.build();
|
.build();
|
||||||
return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void of_totalElements_equals_list_size_for_unpaged_shortcut() {
|
void withMatchData_total_equals_list_size() {
|
||||||
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() {
|
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
|
List<Document> docs = List.of(doc(id));
|
||||||
Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf")
|
Map<UUID, SearchMatchData> matchData = Map.of(id, SearchMatchData.empty());
|
||||||
.status(DocumentStatus.UPLOADED).build();
|
|
||||||
DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor));
|
|
||||||
|
|
||||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
|
DocumentSearchResult result = DocumentSearchResult.withMatchData(docs, matchData);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(1);
|
assertThat(result.total()).isEqualTo(1L);
|
||||||
assertThat(result.items().get(0).completionPercentage()).isEqualTo(75);
|
|
||||||
assertThat(result.items().get(0).contributors()).containsExactly(actor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void items_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
void withMatchData_exposes_match_data_map() {
|
||||||
Schema schema = DocumentSearchResult.class.getDeclaredField("items").getAnnotation(Schema.class);
|
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).isNotNull();
|
||||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void totalElements_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||||
Schema schema = DocumentSearchResult.class.getDeclaredField("totalElements").getAnnotation(Schema.class);
|
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class);
|
||||||
assertThat(schema).isNotNull();
|
assertThat(schema).isNotNull();
|
||||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void paging_components_are_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
|
||||||
for (String name : List.of("pageNumber", "pageSize", "totalPages")) {
|
|
||||||
Schema schema = DocumentSearchResult.class.getDeclaredField(name).getAnnotation(Schema.class);
|
|
||||||
assertThat(schema).as(name + " must have @Schema").isNotNull();
|
|
||||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() + "\"");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -179,22 +179,6 @@ class DocumentFtsTest {
|
|||||||
assertThat(ids).isEmpty();
|
assertThat(ids).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void should_find_document_whose_transcription_contains_word_that_stems_to_german_stop_word() {
|
|
||||||
// "Wille" stems to "will" via the German Snowball stemmer.
|
|
||||||
// "will" is also a German stop word, so to_tsquery('german','will:*') drops it.
|
|
||||||
// The prefix-transform step must use to_tsquery('simple',...) to avoid this.
|
|
||||||
Document doc = documentRepository.saveAndFlush(document("Foto"));
|
|
||||||
UUID annotationId = annotation(doc.getId());
|
|
||||||
blockRepository.saveAndFlush(block(doc.getId(), annotationId, "Der Wille des Volkes", 0));
|
|
||||||
em.flush();
|
|
||||||
em.clear();
|
|
||||||
|
|
||||||
List<UUID> ids = documentRepository.findRankedIdsByFts("Wille");
|
|
||||||
|
|
||||||
assertThat(ids).contains(doc.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
void should_not_throw_when_query_contains_invalid_tsquery_syntax() {
|
||||||
documentRepository.saveAndFlush(document("Brief"));
|
documentRepository.saveAndFlush(document("Brief"));
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
|||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.model.ThumbnailAspect;
|
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
@@ -66,40 +65,6 @@ class DocumentRepositoryTest {
|
|||||||
assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
|
assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── thumbnailAspect + pageCount round-trip ───────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void save_persistsThumbnailAspectAndPageCount() {
|
|
||||||
Document document = Document.builder()
|
|
||||||
.title("Mit Aspekt")
|
|
||||||
.originalFilename("aspect.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.thumbnailAspect(ThumbnailAspect.LANDSCAPE)
|
|
||||||
.pageCount(7)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
Document saved = documentRepository.save(document);
|
|
||||||
Document found = documentRepository.findById(saved.getId()).orElseThrow();
|
|
||||||
|
|
||||||
assertThat(found.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
|
|
||||||
assertThat(found.getPageCount()).isEqualTo(7);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void save_thumbnailAspectAndPageCount_defaultToNull() {
|
|
||||||
Document document = Document.builder()
|
|
||||||
.title("Ohne Aspekt")
|
|
||||||
.originalFilename("no_aspect.pdf")
|
|
||||||
.status(DocumentStatus.PLACEHOLDER)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
Document saved = documentRepository.save(document);
|
|
||||||
Document found = documentRepository.findById(saved.getId()).orElseThrow();
|
|
||||||
|
|
||||||
assertThat(found.getThumbnailAspect()).isNull();
|
|
||||||
assertThat(found.getPageCount()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── findByStatus ─────────────────────────────────────────────────────────
|
// ─── findByStatus ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -302,102 +302,6 @@ class MigrationIntegrationTest {
|
|||||||
).isInstanceOf(DataIntegrityViolationException.class);
|
).isInstanceOf(DataIntegrityViolationException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── V53: add thumbnail_aspect + page_count columns to documents ─────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void v53_thumbnailAspectColumn_existsAndIsNullable() {
|
|
||||||
UUID docId = createDocument();
|
|
||||||
|
|
||||||
// Column must exist and accept NULL (freshly-created doc has no thumbnail yet)
|
|
||||||
String aspect = jdbc.queryForObject(
|
|
||||||
"SELECT thumbnail_aspect FROM documents WHERE id = ?", String.class, docId);
|
|
||||||
assertThat(aspect).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void v53_pageCountColumn_existsAndIsNullable() {
|
|
||||||
UUID docId = createDocument();
|
|
||||||
|
|
||||||
Integer pageCount = jdbc.queryForObject(
|
|
||||||
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
|
|
||||||
assertThat(pageCount).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void v53_thumbnailAspectColumn_acceptsPortraitAndLandscape() {
|
|
||||||
UUID docId = createDocument();
|
|
||||||
|
|
||||||
int portraitRows = jdbc.update(
|
|
||||||
"UPDATE documents SET thumbnail_aspect = 'PORTRAIT' WHERE id = ?", docId);
|
|
||||||
assertThat(portraitRows).isEqualTo(1);
|
|
||||||
|
|
||||||
int landscapeRows = jdbc.update(
|
|
||||||
"UPDATE documents SET thumbnail_aspect = 'LANDSCAPE' WHERE id = ?", docId);
|
|
||||||
assertThat(landscapeRows).isEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void v53_pageCountColumn_storesInteger() {
|
|
||||||
UUID docId = createDocument();
|
|
||||||
|
|
||||||
jdbc.update("UPDATE documents SET page_count = 4 WHERE id = ?", docId);
|
|
||||||
|
|
||||||
Integer stored = jdbc.queryForObject(
|
|
||||||
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
|
|
||||||
assertThat(stored).isEqualTo(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── V51: backfill annotation_id on block comments and notifications ─────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void v51_backfillsAnnotationIdOnBlockCommentsFromTheirBlocks() {
|
|
||||||
UUID docId = createDocument();
|
|
||||||
UUID annotationId = insertAnnotation(docId);
|
|
||||||
UUID blockId = insertBlock(docId, annotationId);
|
|
||||||
UUID commentId = insertBlockCommentWithNullAnnotationId(docId, blockId);
|
|
||||||
|
|
||||||
jdbc.update(V51_BACKFILL_COMMENTS_SQL);
|
|
||||||
|
|
||||||
UUID stored = jdbc.queryForObject(
|
|
||||||
"SELECT annotation_id FROM document_comments WHERE id = ?",
|
|
||||||
UUID.class, commentId);
|
|
||||||
assertThat(stored).isEqualTo(annotationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void v51_backfillsAnnotationIdOnNotificationsFromTheirReferencedComment() {
|
|
||||||
UUID docId = createDocument();
|
|
||||||
UUID userId = insertUser("recipient-" + UUID.randomUUID() + "@example.com");
|
|
||||||
UUID annotationId = insertAnnotation(docId);
|
|
||||||
UUID blockId = insertBlock(docId, annotationId);
|
|
||||||
UUID commentId = insertBlockCommentWithAnnotationId(docId, blockId, annotationId);
|
|
||||||
UUID notificationId = insertNotificationWithNullAnnotationId(docId, commentId, userId);
|
|
||||||
|
|
||||||
jdbc.update(V51_BACKFILL_NOTIFICATIONS_SQL);
|
|
||||||
|
|
||||||
UUID stored = jdbc.queryForObject(
|
|
||||||
"SELECT annotation_id FROM notifications WHERE id = ?",
|
|
||||||
UUID.class, notificationId);
|
|
||||||
assertThat(stored).isEqualTo(annotationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final String V51_BACKFILL_COMMENTS_SQL = """
|
|
||||||
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
|
|
||||||
""";
|
|
||||||
|
|
||||||
private static final String V51_BACKFILL_NOTIFICATIONS_SQL = """
|
|
||||||
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
|
|
||||||
""";
|
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private UUID createPerson(String firstName, String lastName) {
|
private UUID createPerson(String firstName, String lastName) {
|
||||||
@@ -422,63 +326,4 @@ class MigrationIntegrationTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
return doc.getId();
|
return doc.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
private UUID insertAnnotation(UUID docId) {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
jdbc.update("""
|
|
||||||
INSERT INTO document_annotations
|
|
||||||
(id, document_id, page_number, x, y, width, height, color)
|
|
||||||
VALUES (?, ?, 1, 0.1, 0.1, 0.3, 0.1, '#00C7B1')
|
|
||||||
""", id, docId);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID insertBlock(UUID docId, UUID annotationId) {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
jdbc.update("""
|
|
||||||
INSERT INTO transcription_blocks
|
|
||||||
(id, annotation_id, document_id, text, sort_order)
|
|
||||||
VALUES (?, ?, ?, '', 0)
|
|
||||||
""", id, annotationId, docId);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID insertUser(String email) {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
jdbc.update("""
|
|
||||||
INSERT INTO users (id, email, password, enabled, notify_on_reply, notify_on_mention)
|
|
||||||
VALUES (?, ?, 'hash', true, false, false)
|
|
||||||
""", id, email);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID insertBlockCommentWithNullAnnotationId(UUID docId, UUID blockId) {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
jdbc.update("""
|
|
||||||
INSERT INTO document_comments
|
|
||||||
(id, document_id, block_id, annotation_id, author_name, content)
|
|
||||||
VALUES (?, ?, ?, NULL, 'Tester', 'Hi')
|
|
||||||
""", id, docId, blockId);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID insertBlockCommentWithAnnotationId(UUID docId, UUID blockId, UUID annotationId) {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
jdbc.update("""
|
|
||||||
INSERT INTO document_comments
|
|
||||||
(id, document_id, block_id, annotation_id, author_name, content)
|
|
||||||
VALUES (?, ?, ?, ?, 'Tester', 'Hi')
|
|
||||||
""", id, docId, blockId, annotationId);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private UUID insertNotificationWithNullAnnotationId(UUID docId, UUID commentId, UUID recipientId) {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
jdbc.update("""
|
|
||||||
INSERT INTO notifications
|
|
||||||
(id, recipient_id, type, document_id, reference_id, annotation_id, read, actor_name)
|
|
||||||
VALUES (?, ?, 'MENTION', ?, ?, NULL, false, 'Tester')
|
|
||||||
""", id, recipientId, docId, commentId);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.repository;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
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.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
@DataJpaTest
|
|
||||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
|
||||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
|
||||||
class TranscriptionBlockRepositoryIntegrationTest {
|
|
||||||
|
|
||||||
static final UUID DOC_A = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
|
||||||
static final UUID DOC_B = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
|
||||||
static final UUID ANN_A = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
|
||||||
static final UUID ANN_B = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
|
||||||
|
|
||||||
@Autowired TranscriptionBlockRepository repository;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Sql(statements = {
|
|
||||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
|
||||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
|
||||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
|
|
||||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, true)"
|
|
||||||
})
|
|
||||||
void findCompletionStats_returns_100_when_all_blocks_reviewed() {
|
|
||||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
|
||||||
|
|
||||||
assertThat(rows).hasSize(1);
|
|
||||||
assertThat(rows.get(0).getDocumentId()).isEqualTo(DOC_A);
|
|
||||||
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Sql(statements = {
|
|
||||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
|
||||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
|
||||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, false)",
|
|
||||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, false)"
|
|
||||||
})
|
|
||||||
void findCompletionStats_returns_0_when_no_blocks_reviewed() {
|
|
||||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
|
||||||
|
|
||||||
assertThat(rows).hasSize(1);
|
|
||||||
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Sql(statements = {
|
|
||||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')"
|
|
||||||
})
|
|
||||||
void findCompletionStats_returns_empty_when_document_has_no_blocks() {
|
|
||||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
|
||||||
|
|
||||||
assertThat(rows).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Sql(statements = {
|
|
||||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
|
||||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
|
||||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
|
|
||||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, false)",
|
|
||||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 2, false)",
|
|
||||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 3, false)"
|
|
||||||
})
|
|
||||||
void findCompletionStats_rounds_partial_completion_correctly() {
|
|
||||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A));
|
|
||||||
|
|
||||||
assertThat(rows).hasSize(1);
|
|
||||||
assertThat(rows.get(0).getCompletionPercentage()).isEqualTo(25);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Sql(statements = {
|
|
||||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
|
||||||
"INSERT INTO documents (id, title, original_filename, status) VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Doc B', 'b.pdf', 'PLACEHOLDER')",
|
|
||||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
|
||||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 1, 0, 0, 1, 1, '#fff')",
|
|
||||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, true)",
|
|
||||||
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 0, false)"
|
|
||||||
})
|
|
||||||
void findCompletionStats_handles_multiple_documents_in_one_call() {
|
|
||||||
List<CompletionStatsRow> rows = repository.findCompletionStatsForDocuments(List.of(DOC_A, DOC_B));
|
|
||||||
|
|
||||||
Map<UUID, Integer> byDoc = rows.stream()
|
|
||||||
.collect(Collectors.toMap(CompletionStatsRow::getDocumentId, CompletionStatsRow::getCompletionPercentage));
|
|
||||||
|
|
||||||
assertThat(byDoc).containsEntry(DOC_A, 100);
|
|
||||||
assertThat(byDoc).containsEntry(DOC_B, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import org.raddatz.familienarchiv.audit.AuditService;
|
|||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
|
||||||
import org.raddatz.familienarchiv.model.UserGroup;
|
import org.raddatz.familienarchiv.model.UserGroup;
|
||||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
|
|
||||||
@@ -40,9 +39,54 @@ class CommentServiceTest {
|
|||||||
@Mock UserService userService;
|
@Mock UserService userService;
|
||||||
@Mock NotificationService notificationService;
|
@Mock NotificationService notificationService;
|
||||||
@Mock AuditService auditService;
|
@Mock AuditService auditService;
|
||||||
@Mock TranscriptionService transcriptionService;
|
|
||||||
@InjectMocks CommentService commentService;
|
@InjectMocks CommentService commentService;
|
||||||
|
|
||||||
|
// ─── postComment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_capturesAuthorNameAtWriteTime() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder()
|
||||||
|
.id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("Müller").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_fallsBackToUsername_whenNamesAreBlank() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans42@example.com").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID mentionedId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||||
|
AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hey @Anna S").build();
|
||||||
|
|
||||||
|
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postComment(docId, null, "Hey @Anna S", List.of(mentionedId), author);
|
||||||
|
|
||||||
|
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── replyToComment ───────────────────────────────────────────────────────
|
// ─── replyToComment ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -178,7 +222,7 @@ class CommentServiceTest {
|
|||||||
.id(commentId).documentId(docId).authorId(authorId)
|
.id(commentId).documentId(docId).authorId(authorId)
|
||||||
.content("Original").authorName("Hans").createdAt(created).build();
|
.content("Original").authorName("Hans").createdAt(created).build();
|
||||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
stubSaveAssigningRandomId();
|
when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
|
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
|
||||||
|
|
||||||
@@ -238,6 +282,28 @@ class CommentServiceTest {
|
|||||||
verify(commentRepository).delete(comment);
|
verify(commentRepository).delete(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── getCommentsForDocument ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCommentsForDocument_returnsRootsWithRepliesAttached() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).documentId(docId).authorName("Hans").content("Root").build();
|
||||||
|
DocumentComment reply = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorName("Anna").content("Reply").build();
|
||||||
|
|
||||||
|
when(commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(docId))
|
||||||
|
.thenReturn(List.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(reply));
|
||||||
|
|
||||||
|
List<DocumentComment> result = commentService.getCommentsForDocument(docId);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getReplies()).containsExactly(reply);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── replyToComment — reply with null authorId in thread ─────────────────
|
// ─── replyToComment — reply with null authorId in thread ─────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -264,6 +330,82 @@ class CommentServiceTest {
|
|||||||
verify(notificationService).notifyReply(eq(saved), anySet());
|
verify(notificationService).notifyReply(eq(saved), anySet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── resolveAuthorName edge cases ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_fallsBackToUsername_whenFirstNameBlankAndLastNameNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||||
|
.firstName(" ").lastName(null).build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("user42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_fallsBackToUsername_whenFirstNameNullAndLastNameBlank() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||||
|
.firstName(null).lastName(" ").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("user42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_includesOnlyFirstName_whenLastNameIsNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||||
|
.firstName("Hans").lastName(null).build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans").content("Hi").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
// first != null && !blank → true; last == null → entire condition false → returns stripped first
|
||||||
|
verify(commentRepository).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_includesOnlyLastName_whenFirstNameIsNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||||
|
.firstName(null).lastName("Müller").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Müller").content("Hi").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
// No exception — name resolution with null first name strips cleanly
|
||||||
|
verify(commentRepository).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── saveMentions — null/empty guard ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com")
|
||||||
|
.firstName("Hans").lastName("M").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hi").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postComment(docId, null, "Hi", null, author);
|
||||||
|
|
||||||
|
verify(userService, never()).findAllById(anyList());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── collectParticipantIds — non-null authorId in reply ──────────────────
|
// ─── collectParticipantIds — non-null authorId in reply ──────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -317,6 +459,26 @@ class CommentServiceTest {
|
|||||||
verify(notificationService).notifyReply(eq(saved), anySet());
|
verify(notificationService).notifyReply(eq(saved), anySet());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── getCommentsForAnnotation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCommentsForAnnotation_returnsRootsForAnnotation() {
|
||||||
|
UUID annotationId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).annotationId(annotationId).authorName("Hans").content("Root").build();
|
||||||
|
|
||||||
|
when(commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId))
|
||||||
|
.thenReturn(List.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<DocumentComment> result = commentService.getCommentsForAnnotation(annotationId);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getAnnotationId()).isEqualTo(annotationId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private AppUser buildAdmin() {
|
private AppUser buildAdmin() {
|
||||||
@@ -333,6 +495,65 @@ class CommentServiceTest {
|
|||||||
|
|
||||||
// ─── audit: COMMENT_ADDED and MENTION_CREATED ─────────────────────────────
|
// ─── audit: COMMENT_ADDED and MENTION_CREATED ─────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_logsCommentAdded() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID savedId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(savedId).documentId(docId).authorName("Hans M").content("Hello").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postComment(docId, null, "Hello", List.of(), author);
|
||||||
|
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
eq(AuditKind.COMMENT_ADDED),
|
||||||
|
eq(author.getId()),
|
||||||
|
eq(docId),
|
||||||
|
argThat(p -> savedId.toString().equals(p.get("commentId"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_logsMentionCreated_oncePerMentionedUser() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID savedId = UUID.randomUUID();
|
||||||
|
UUID mentionedId1 = UUID.randomUUID();
|
||||||
|
UUID mentionedId2 = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||||
|
AppUser mentioned1 = AppUser.builder().id(mentionedId1).email("anna@example.com").firstName("Anna").lastName("S").build();
|
||||||
|
AppUser mentioned2 = AppUser.builder().id(mentionedId2).email("bob@example.com").firstName("Bob").lastName("J").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(savedId).documentId(docId).authorName("Hans M").content("Hey @Anna @Bob").build();
|
||||||
|
when(userService.findAllById(List.of(mentionedId1, mentionedId2))).thenReturn(List.of(mentioned1, mentioned2));
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postComment(docId, null, "Hey @Anna @Bob", List.of(mentionedId1, mentionedId2), author);
|
||||||
|
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
eq(AuditKind.MENTION_CREATED),
|
||||||
|
eq(author.getId()),
|
||||||
|
eq(docId),
|
||||||
|
argThat(p -> mentionedId1.toString().equals(p.get("mentionedUserId"))));
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
eq(AuditKind.MENTION_CREATED),
|
||||||
|
eq(author.getId()),
|
||||||
|
eq(docId),
|
||||||
|
argThat(p -> mentionedId2.toString().equals(p.get("mentionedUserId"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_doesNotLogMentionCreated_whenNoMentions() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hello").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postComment(docId, null, "Hello", List.of(), author);
|
||||||
|
|
||||||
|
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void replyToComment_logsCommentAdded() {
|
void replyToComment_logsCommentAdded() {
|
||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
@@ -390,8 +611,6 @@ class CommentServiceTest {
|
|||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("B").build();
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("B").build();
|
||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(savedId).documentId(docId).blockId(blockId).authorName("Felix B").content("Nice").build();
|
.id(savedId).documentId(docId).blockId(blockId).authorName("Felix B").content("Nice").build();
|
||||||
when(transcriptionService.getBlock(docId, blockId))
|
|
||||||
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(docId).annotationId(UUID.randomUUID()).sortOrder(0).build());
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
commentService.postBlockComment(docId, blockId, "Nice", List.of(), author);
|
commentService.postBlockComment(docId, blockId, "Nice", List.of(), author);
|
||||||
@@ -424,10 +643,7 @@ class CommentServiceTest {
|
|||||||
void postBlockComment_setsBlockIdOnComment() {
|
void postBlockComment_setsBlockIdOnComment() {
|
||||||
UUID documentId = UUID.randomUUID();
|
UUID documentId = UUID.randomUUID();
|
||||||
UUID blockId = UUID.randomUUID();
|
UUID blockId = UUID.randomUUID();
|
||||||
UUID annotationId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
|
||||||
when(transcriptionService.getBlock(documentId, blockId))
|
|
||||||
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(documentId).annotationId(annotationId).sortOrder(0).build());
|
|
||||||
when(commentRepository.save(any())).thenAnswer(inv -> {
|
when(commentRepository.save(any())).thenAnswer(inv -> {
|
||||||
DocumentComment c = inv.getArgument(0);
|
DocumentComment c = inv.getArgument(0);
|
||||||
c.setId(UUID.randomUUID());
|
c.setId(UUID.randomUUID());
|
||||||
@@ -441,275 +657,4 @@ class CommentServiceTest {
|
|||||||
assertThat(result.getDocumentId()).isEqualTo(documentId);
|
assertThat(result.getDocumentId()).isEqualTo(documentId);
|
||||||
assertThat(result.getContent()).isEqualTo("Looks like Breslau");
|
assertThat(result.getContent()).isEqualTo("Looks like Breslau");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_setsAnnotationIdFromBlock() {
|
|
||||||
UUID documentId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
UUID annotationId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
|
|
||||||
when(transcriptionService.getBlock(documentId, blockId))
|
|
||||||
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(documentId).annotationId(annotationId).sortOrder(0).build());
|
|
||||||
when(commentRepository.save(any())).thenAnswer(inv -> {
|
|
||||||
DocumentComment c = inv.getArgument(0);
|
|
||||||
c.setId(UUID.randomUUID());
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
|
|
||||||
DocumentComment result = commentService.postBlockComment(
|
|
||||||
documentId, blockId, "Nice work", List.of(), author);
|
|
||||||
|
|
||||||
assertThat(result.getAnnotationId()).isEqualTo(annotationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_propagatesNotFound_whenBlockDoesNotExist() {
|
|
||||||
UUID documentId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
|
|
||||||
when(transcriptionService.getBlock(documentId, blockId))
|
|
||||||
.thenThrow(DomainException.notFound(
|
|
||||||
org.raddatz.familienarchiv.exception.ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND,
|
|
||||||
"Transcription block not found: " + blockId));
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> commentService.postBlockComment(documentId, blockId, "Hi", List.of(), author))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.hasMessageContaining("Transcription block not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── postBlockComment — authorName resolution ────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_capturesAuthorNameAtWriteTime() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder()
|
|
||||||
.id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("Müller").build();
|
|
||||||
stubBlock(docId, blockId);
|
|
||||||
stubSaveAssigningRandomId();
|
|
||||||
|
|
||||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Test", List.of(), author);
|
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_fallsBackToEmail_whenNamesAreBlank() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans42@example.com").build();
|
|
||||||
stubBlock(docId, blockId);
|
|
||||||
stubSaveAssigningRandomId();
|
|
||||||
|
|
||||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Test", List.of(), author);
|
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("hans42@example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_fallsBackToEmail_whenFirstNameBlankAndLastNameNull() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
|
||||||
.firstName(" ").lastName(null).build();
|
|
||||||
stubBlock(docId, blockId);
|
|
||||||
stubSaveAssigningRandomId();
|
|
||||||
|
|
||||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("user42@example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_fallsBackToEmail_whenFirstNameNullAndLastNameBlank() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
|
||||||
.firstName(null).lastName(" ").build();
|
|
||||||
stubBlock(docId, blockId);
|
|
||||||
stubSaveAssigningRandomId();
|
|
||||||
|
|
||||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("user42@example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_usesFirstNameAlone_whenLastNameIsNull() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
|
||||||
.firstName("Hans").lastName(null).build();
|
|
||||||
stubBlock(docId, blockId);
|
|
||||||
stubSaveAssigningRandomId();
|
|
||||||
|
|
||||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("Hans");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_usesLastNameAlone_whenFirstNameIsNull() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
|
||||||
.firstName(null).lastName("Müller").build();
|
|
||||||
stubBlock(docId, blockId);
|
|
||||||
stubSaveAssigningRandomId();
|
|
||||||
|
|
||||||
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("Müller");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── postBlockComment — mentions ─────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
UUID mentionedId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
|
||||||
AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).blockId(blockId).authorName("Hans M").content("Hey @Anna S").build();
|
|
||||||
stubBlock(docId, blockId);
|
|
||||||
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
commentService.postBlockComment(docId, blockId, "Hey @Anna S", List.of(mentionedId), author);
|
|
||||||
|
|
||||||
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com")
|
|
||||||
.firstName("Hans").lastName("M").build();
|
|
||||||
stubBlock(docId, blockId);
|
|
||||||
stubSaveAssigningRandomId();
|
|
||||||
|
|
||||||
commentService.postBlockComment(docId, blockId, "Hi", null, author);
|
|
||||||
|
|
||||||
verify(userService, never()).findAllById(anyList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_logsMentionCreated_oncePerMentionedUser() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
UUID savedId = UUID.randomUUID();
|
|
||||||
UUID mentionedId1 = UUID.randomUUID();
|
|
||||||
UUID mentionedId2 = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
|
||||||
AppUser mentioned1 = AppUser.builder().id(mentionedId1).email("anna@example.com").firstName("Anna").lastName("S").build();
|
|
||||||
AppUser mentioned2 = AppUser.builder().id(mentionedId2).email("bob@example.com").firstName("Bob").lastName("J").build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(savedId).documentId(docId).blockId(blockId).authorName("Hans M").content("Hey @Anna @Bob").build();
|
|
||||||
stubBlock(docId, blockId);
|
|
||||||
when(userService.findAllById(List.of(mentionedId1, mentionedId2))).thenReturn(List.of(mentioned1, mentioned2));
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
commentService.postBlockComment(docId, blockId, "Hey @Anna @Bob", List.of(mentionedId1, mentionedId2), author);
|
|
||||||
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
eq(AuditKind.MENTION_CREATED),
|
|
||||||
eq(author.getId()),
|
|
||||||
eq(docId),
|
|
||||||
argThat(p -> mentionedId1.toString().equals(p.get("mentionedUserId"))));
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
eq(AuditKind.MENTION_CREATED),
|
|
||||||
eq(author.getId()),
|
|
||||||
eq(docId),
|
|
||||||
argThat(p -> mentionedId2.toString().equals(p.get("mentionedUserId"))));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postBlockComment_doesNotLogMentionCreated_whenNoMentions() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID blockId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).blockId(blockId).authorName("Hans M").content("Hello").build();
|
|
||||||
stubBlock(docId, blockId);
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
commentService.postBlockComment(docId, blockId, "Hello", List.of(), author);
|
|
||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── findAnnotationIdsByIds ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findAnnotationIdsByIds_returnsMap_forKnownIds() {
|
|
||||||
UUID commentA = UUID.randomUUID();
|
|
||||||
UUID annotationA = UUID.randomUUID();
|
|
||||||
UUID commentB = UUID.randomUUID();
|
|
||||||
UUID annotationB = UUID.randomUUID();
|
|
||||||
when(commentRepository.findAllById(List.of(commentA, commentB)))
|
|
||||||
.thenReturn(List.of(
|
|
||||||
DocumentComment.builder().id(commentA).annotationId(annotationA).build(),
|
|
||||||
DocumentComment.builder().id(commentB).annotationId(annotationB).build()
|
|
||||||
));
|
|
||||||
|
|
||||||
assertThat(commentService.findAnnotationIdsByIds(List.of(commentA, commentB)))
|
|
||||||
.containsOnly(
|
|
||||||
java.util.Map.entry(commentA, annotationA),
|
|
||||||
java.util.Map.entry(commentB, annotationB)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findAnnotationIdsByIds_returnsEmptyMap_forEmptyInput() {
|
|
||||||
assertThat(commentService.findAnnotationIdsByIds(List.of())).isEmpty();
|
|
||||||
verify(commentRepository, never()).findAllById(anyList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findAnnotationIdsByIds_omitsUnknownIds() {
|
|
||||||
UUID known = UUID.randomUUID();
|
|
||||||
UUID knownAnnotation = UUID.randomUUID();
|
|
||||||
UUID missing = UUID.randomUUID();
|
|
||||||
when(commentRepository.findAllById(List.of(known, missing)))
|
|
||||||
.thenReturn(List.of(
|
|
||||||
DocumentComment.builder().id(known).annotationId(knownAnnotation).build()
|
|
||||||
));
|
|
||||||
|
|
||||||
assertThat(commentService.findAnnotationIdsByIds(List.of(known, missing)))
|
|
||||||
.containsOnly(java.util.Map.entry(known, knownAnnotation))
|
|
||||||
.doesNotContainKey(missing);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findAnnotationIdsByIds_omitsCommentsWithNullAnnotationId() {
|
|
||||||
UUID legacy = UUID.randomUUID();
|
|
||||||
UUID block = UUID.randomUUID();
|
|
||||||
UUID annotation = UUID.randomUUID();
|
|
||||||
when(commentRepository.findAllById(List.of(legacy, block)))
|
|
||||||
.thenReturn(List.of(
|
|
||||||
DocumentComment.builder().id(legacy).annotationId(null).build(),
|
|
||||||
DocumentComment.builder().id(block).annotationId(annotation).build()
|
|
||||||
));
|
|
||||||
|
|
||||||
assertThat(commentService.findAnnotationIdsByIds(List.of(legacy, block)))
|
|
||||||
.containsOnly(java.util.Map.entry(block, annotation))
|
|
||||||
.doesNotContainKey(legacy);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void stubBlock(UUID docId, UUID blockId) {
|
|
||||||
when(transcriptionService.getBlock(docId, blockId))
|
|
||||||
.thenReturn(TranscriptionBlock.builder()
|
|
||||||
.id(blockId).documentId(docId).annotationId(UUID.randomUUID()).sortOrder(0).build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void stubSaveAssigningRandomId() {
|
|
||||||
when(commentRepository.save(any())).thenAnswer(inv -> {
|
|
||||||
DocumentComment c = inv.getArgument(0);
|
|
||||||
if (c.getId() == null) c.setId(UUID.randomUUID());
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.test.annotation.DirtiesContext;
|
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
|
|
||||||
* Specification→Pageable→Page→DTO path that unit tests mock around. Seeds 120
|
|
||||||
* UPLOADED documents and asserts the slice/total/totalPages arithmetic holds
|
|
||||||
* against the actual JPA query.
|
|
||||||
*
|
|
||||||
* <p>Closes the integration-coverage gap Sara flagged on PR #316.
|
|
||||||
*/
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
|
||||||
@ActiveProfiles("test")
|
|
||||||
@Import(PostgresContainerConfig.class)
|
|
||||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
|
||||||
class DocumentSearchPagedIntegrationTest {
|
|
||||||
|
|
||||||
private static final int FIXTURE_SIZE = 120;
|
|
||||||
|
|
||||||
@MockitoBean S3Client s3Client;
|
|
||||||
@Autowired DocumentService documentService;
|
|
||||||
@Autowired DocumentRepository documentRepository;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void seed() {
|
|
||||||
// Deterministic date spread so DATE-DESC order is predictable:
|
|
||||||
// document #0 has the oldest date, document #119 has the newest.
|
|
||||||
for (int i = 0; i < FIXTURE_SIZE; i++) {
|
|
||||||
Document doc = Document.builder()
|
|
||||||
.title("Dok-" + String.format("%03d", i))
|
|
||||||
.originalFilename("dok-" + i + ".pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.documentDate(LocalDate.of(1900, 1, 1).plusDays(i))
|
|
||||||
.build();
|
|
||||||
documentRepository.save(doc);
|
|
||||||
}
|
|
||||||
assertThat(documentRepository.count()).isEqualTo(FIXTURE_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
|
||||||
null, null, null, null, null, null, null, null,
|
|
||||||
DocumentSort.DATE, "DESC", null,
|
|
||||||
PageRequest.of(0, 50));
|
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(50);
|
|
||||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
|
||||||
assertThat(result.pageNumber()).isZero();
|
|
||||||
assertThat(result.pageSize()).isEqualTo(50);
|
|
||||||
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void search_lastPartialPage_returnsRemainingItems() {
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
|
||||||
null, null, null, null, null, null, null, null,
|
|
||||||
DocumentSort.DATE, "DESC", null,
|
|
||||||
PageRequest.of(2, 50));
|
|
||||||
|
|
||||||
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
|
|
||||||
assertThat(result.items()).hasSize(20);
|
|
||||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
|
||||||
assertThat(result.pageNumber()).isEqualTo(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
|
||||||
null, null, null, null, null, null, null, null,
|
|
||||||
DocumentSort.DATE, "DESC", null,
|
|
||||||
PageRequest.of(99, 50));
|
|
||||||
|
|
||||||
assertThat(result.items()).isEmpty();
|
|
||||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void search_senderSort_pageOne_slicesInMemory_withCorrectTotal() {
|
|
||||||
// SENDER sort path fetches all + sorts + slices in-memory (see scaling
|
|
||||||
// comment in DocumentService). Proves that the in-memory slice path
|
|
||||||
// returns the correct total from a real repository fetch.
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
|
||||||
null, null, null, null, null, null, null, null,
|
|
||||||
DocumentSort.SENDER, "asc", null,
|
|
||||||
PageRequest.of(1, 50));
|
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(50);
|
|
||||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
|
||||||
assertThat(result.pageNumber()).isEqualTo(1);
|
|
||||||
assertThat(result.totalPages()).isEqualTo(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void search_differentPagesReturnDisjointSlices() {
|
|
||||||
DocumentSearchResult page0 = documentService.searchDocuments(
|
|
||||||
null, null, null, null, null, null, null, null,
|
|
||||||
DocumentSort.DATE, "DESC", null,
|
|
||||||
PageRequest.of(0, 50));
|
|
||||||
DocumentSearchResult page1 = documentService.searchDocuments(
|
|
||||||
null, null, null, null, null, null, null, null,
|
|
||||||
DocumentSort.DATE, "DESC", null,
|
|
||||||
PageRequest.of(1, 50));
|
|
||||||
|
|
||||||
// No document id should appear on both pages — slicing must be exclusive.
|
|
||||||
var idsOnPage0 = page0.items().stream()
|
|
||||||
.map(item -> item.document().getId())
|
|
||||||
.toList();
|
|
||||||
var idsOnPage1 = page1.items().stream()
|
|
||||||
.map(item -> item.document().getId())
|
|
||||||
.toList();
|
|
||||||
for (UUID id : idsOnPage0) {
|
|
||||||
assertThat(idsOnPage1).doesNotContain(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
@@ -26,16 +24,12 @@ import static org.mockito.Mockito.when;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class DocumentServiceSortTest {
|
class DocumentServiceSortTest {
|
||||||
|
|
||||||
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
|
|
||||||
|
|
||||||
@Mock DocumentRepository documentRepository;
|
@Mock DocumentRepository documentRepository;
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@Mock FileService fileService;
|
@Mock FileService fileService;
|
||||||
@Mock TagService tagService;
|
@Mock TagService tagService;
|
||||||
@Mock DocumentVersionService documentVersionService;
|
@Mock DocumentVersionService documentVersionService;
|
||||||
@Mock AnnotationService annotationService;
|
@Mock AnnotationService annotationService;
|
||||||
@Mock AuditLogQueryService auditLogQueryService;
|
|
||||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
|
||||||
@InjectMocks DocumentService documentService;
|
@InjectMocks DocumentService documentService;
|
||||||
|
|
||||||
// ─── searchDocuments — DATE sort ──────────────────────────────────────────
|
// ─── searchDocuments — DATE sort ──────────────────────────────────────────
|
||||||
@@ -54,16 +48,16 @@ class DocumentServiceSortTest {
|
|||||||
|
|
||||||
// FTS returns id1 first (higher rank), id2 second
|
// FTS returns id1 first (higher rank), id2 second
|
||||||
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
|
||||||
// findAll(spec, pageable) — the correct date path — returns date-DESC order
|
// findAll(spec, sort) — the correct date path — returns date-DESC order
|
||||||
when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
.thenReturn(List.of(newer, older));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null);
|
||||||
|
|
||||||
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.documents()).hasSize(2);
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer doc first
|
assertThat(result.documents().get(0).getId()).isEqualTo(id2); // newer doc first
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
|
// ─── searchDocuments — RELEVANCE sort ─────────────────────────────────────
|
||||||
@@ -81,10 +75,10 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
.thenReturn(List.of(doc2, doc1)); // unordered from DB
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||||
|
|
||||||
// Expect: rank order restored (id1 first)
|
// Expect: rank order restored (id1 first)
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -100,8 +94,8 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(List.of(doc2, doc1));
|
.thenReturn(List.of(doc2, doc1));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
"Brief", null, null, null, null, null, null, null, null, null, null);
|
||||||
|
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.documents().get(0).getId()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,13 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchItem;
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSort;
|
import org.raddatz.familienarchiv.dto.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.dto.MatchOffset;
|
import org.raddatz.familienarchiv.dto.MatchOffset;
|
||||||
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
import org.raddatz.familienarchiv.dto.SearchMatchData;
|
||||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
@@ -25,7 +22,6 @@ import org.raddatz.familienarchiv.model.Tag;
|
|||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.PageRequest;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
@@ -48,12 +44,6 @@ import static org.mockito.Mockito.*;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class DocumentServiceTest {
|
class DocumentServiceTest {
|
||||||
|
|
||||||
// Used by tests that don't care about paging. 10 000 is chosen large enough
|
|
||||||
// to hold any fixture in this file but small enough that totalPages math
|
|
||||||
// stays in int range. Swap to `PageRequest.of(0, 10_000)` elsewhere is a
|
|
||||||
// red flag — use this constant.
|
|
||||||
private static final Pageable UNPAGED = PageRequest.of(0, 10_000);
|
|
||||||
|
|
||||||
@Mock DocumentRepository documentRepository;
|
@Mock DocumentRepository documentRepository;
|
||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@Mock FileService fileService;
|
@Mock FileService fileService;
|
||||||
@@ -61,9 +51,6 @@ class DocumentServiceTest {
|
|||||||
@Mock DocumentVersionService documentVersionService;
|
@Mock DocumentVersionService documentVersionService;
|
||||||
@Mock AnnotationService annotationService;
|
@Mock AnnotationService annotationService;
|
||||||
@Mock AuditService auditService;
|
@Mock AuditService auditService;
|
||||||
@Mock AuditLogQueryService auditLogQueryService;
|
|
||||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
|
||||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
|
||||||
@InjectMocks DocumentService documentService;
|
@InjectMocks DocumentService documentService;
|
||||||
|
|
||||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||||
@@ -121,23 +108,6 @@ class DocumentServiceTest {
|
|||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateDocument_setsArchiveBoxAndFolder() throws Exception {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(documentRepository.save(any())).thenReturn(doc);
|
|
||||||
|
|
||||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
|
||||||
dto.setArchiveBox("K-03");
|
|
||||||
dto.setArchiveFolder("Mappe B");
|
|
||||||
|
|
||||||
documentService.updateDocument(id, dto, null, null);
|
|
||||||
|
|
||||||
assertThat(doc.getArchiveBox()).isEqualTo("K-03");
|
|
||||||
assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -283,107 +253,6 @@ class DocumentServiceTest {
|
|||||||
verify(documentVersionService).recordVersion(any(Document.class));
|
verify(documentVersionService).recordVersion(any(Document.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── thumbnail dispatch ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void storeDocument_dispatchesThumbnailAfterSave() throws Exception {
|
|
||||||
org.springframework.mock.web.MockMultipartFile file =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
|
|
||||||
UUID savedId = UUID.randomUUID();
|
|
||||||
Document saved = Document.builder().id(savedId).originalFilename("new.pdf").build();
|
|
||||||
when(documentRepository.findFirstByOriginalFilename("new.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentRepository.save(any())).thenReturn(saved);
|
|
||||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
|
|
||||||
|
|
||||||
documentService.storeDocument(file, null);
|
|
||||||
|
|
||||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createDocument_dispatchesThumbnail_onlyWhenFileProvided() throws Exception {
|
|
||||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
|
||||||
dto.setTitle("No file");
|
|
||||||
UUID savedId = UUID.randomUUID();
|
|
||||||
Document saved = Document.builder().id(savedId).title("No file")
|
|
||||||
.originalFilename("No file").status(DocumentStatus.PLACEHOLDER).build();
|
|
||||||
when(documentRepository.save(any())).thenReturn(saved);
|
|
||||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
|
||||||
|
|
||||||
documentService.createDocument(dto, null);
|
|
||||||
|
|
||||||
verifyNoInteractions(thumbnailAsyncRunner);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createDocument_dispatchesThumbnail_whenFileProvided() throws Exception {
|
|
||||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
|
||||||
dto.setTitle("With file");
|
|
||||||
org.springframework.mock.web.MockMultipartFile file =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
|
||||||
UUID savedId = UUID.randomUUID();
|
|
||||||
Document saved = Document.builder().id(savedId).title("With file")
|
|
||||||
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
|
|
||||||
when(documentRepository.save(any())).thenReturn(saved);
|
|
||||||
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
|
||||||
when(fileService.uploadFile(any(), any()))
|
|
||||||
.thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));
|
|
||||||
|
|
||||||
documentService.createDocument(dto, file);
|
|
||||||
|
|
||||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateDocument_dispatchesThumbnail_onlyWhenFileReplaced() throws Exception {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document existing = Document.builder()
|
|
||||||
.id(id).title("Doc").originalFilename("old.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED).build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
when(documentRepository.save(any())).thenReturn(existing);
|
|
||||||
|
|
||||||
documentService.updateDocument(id, new DocumentUpdateDTO(), null, null);
|
|
||||||
|
|
||||||
verifyNoInteractions(thumbnailAsyncRunner);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updateDocument_dispatchesThumbnail_whenNewFileProvided() throws Exception {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document existing = Document.builder()
|
|
||||||
.id(id).title("Doc").originalFilename("old.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED).build();
|
|
||||||
org.springframework.mock.web.MockMultipartFile newFile =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1});
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
when(documentRepository.save(any())).thenReturn(existing);
|
|
||||||
when(fileService.uploadFile(any(), any()))
|
|
||||||
.thenReturn(new FileService.UploadResult("documents/new.pdf", "hash"));
|
|
||||||
|
|
||||||
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile, null);
|
|
||||||
|
|
||||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void attachFile_dispatchesThumbnailAfterSave() throws Exception {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document existing = Document.builder()
|
|
||||||
.id(id).title("Placeholder").originalFilename("placeholder")
|
|
||||||
.status(DocumentStatus.PLACEHOLDER).build();
|
|
||||||
org.springframework.mock.web.MockMultipartFile file =
|
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
|
||||||
when(documentRepository.save(any())).thenReturn(existing);
|
|
||||||
when(fileService.uploadFile(any(), any()))
|
|
||||||
.thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash"));
|
|
||||||
|
|
||||||
documentService.attachFile(id, file, null);
|
|
||||||
|
|
||||||
verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── storeDocument ───────────────────────────────────────────────────────
|
// ─── storeDocument ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -517,22 +386,6 @@ class DocumentServiceTest {
|
|||||||
assertThat(result.get(0).title()).isEqualTo("Unvollständig");
|
assertThat(result.get(0).title()).isEqualTo("Unvollständig");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void findIncompleteDocuments_mapsUploadedAtFromCreatedAt() {
|
|
||||||
java.time.LocalDateTime createdAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0);
|
|
||||||
Document doc = Document.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.title("Recent")
|
|
||||||
.createdAt(createdAt)
|
|
||||||
.build();
|
|
||||||
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
|
|
||||||
.thenReturn(new PageImpl<>(List.of(doc)));
|
|
||||||
|
|
||||||
List<IncompleteDocumentDTO> result = documentService.findIncompleteDocuments(3);
|
|
||||||
|
|
||||||
assertThat(result.get(0).uploadedAt()).isEqualTo(createdAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findIncompleteDocuments_passesSizeToPageable() {
|
void findIncompleteDocuments_passesSizeToPageable() {
|
||||||
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
|
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
|
||||||
@@ -1348,124 +1201,26 @@ class DocumentServiceTest {
|
|||||||
assertThat(result).isNull();
|
assertThat(result).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── searchDocuments — pagination ────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchDocuments_fastPath_usesFindAllWithPageable_notWithSort() {
|
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
|
||||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
|
||||||
org.springframework.data.domain.PageRequest.of(1, 50));
|
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
|
||||||
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchDocuments_fastPath_propagatesPageableToDatabase() {
|
|
||||||
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
|
||||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
|
||||||
org.springframework.data.domain.PageRequest.of(3, 25));
|
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
|
||||||
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
|
|
||||||
assertThat(captor.getValue().getPageSize()).isEqualTo(25);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchDocuments_fastPath_returnsPageableTotalsOnResult() {
|
|
||||||
// The service MUST report the full match count from Page.getTotalElements(),
|
|
||||||
// not the slice size — otherwise the frontend's "N Briefe gefunden" label is wrong.
|
|
||||||
Document d = Document.builder().id(UUID.randomUUID()).title("T").build();
|
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
|
||||||
.thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));
|
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
|
||||||
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
|
|
||||||
org.springframework.data.domain.PageRequest.of(0, 50));
|
|
||||||
|
|
||||||
assertThat(result.totalElements()).isEqualTo(120L);
|
|
||||||
assertThat(result.pageNumber()).isZero();
|
|
||||||
assertThat(result.pageSize()).isEqualTo(50);
|
|
||||||
assertThat(result.totalPages()).isEqualTo(3); // ceil(120/50)
|
|
||||||
assertThat(result.items()).hasSize(1); // only the slice is enriched
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
|
|
||||||
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
|
|
||||||
// back with totalElements = 120.
|
|
||||||
List<Document> all = new java.util.ArrayList<>();
|
|
||||||
for (int i = 0; i < 120; i++) {
|
|
||||||
Person p = Person.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.firstName("F" + i)
|
|
||||||
.lastName(String.format("L%03d", i))
|
|
||||||
.build();
|
|
||||||
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
|
||||||
}
|
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
|
||||||
.thenReturn(all);
|
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
|
||||||
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
|
||||||
org.springframework.data.domain.PageRequest.of(1, 50));
|
|
||||||
|
|
||||||
assertThat(result.totalElements()).isEqualTo(120L);
|
|
||||||
assertThat(result.pageNumber()).isEqualTo(1);
|
|
||||||
assertThat(result.pageSize()).isEqualTo(50);
|
|
||||||
assertThat(result.totalPages()).isEqualTo(3);
|
|
||||||
assertThat(result.items()).hasSize(50);
|
|
||||||
// Page 1 (offset 50) under ascending sender sort should start at L050
|
|
||||||
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchDocuments_pageBeyondLast_returnsEmptyContentAndCorrectTotal() {
|
|
||||||
// Guards the JPA edge case where page * size > totalElements.
|
|
||||||
// Must not throw, must return empty content + correct totalElements.
|
|
||||||
List<Document> all = new java.util.ArrayList<>();
|
|
||||||
for (int i = 0; i < 30; i++) {
|
|
||||||
Person p = Person.builder().id(UUID.randomUUID()).lastName(String.format("L%02d", i)).build();
|
|
||||||
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
|
|
||||||
}
|
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
|
||||||
.thenReturn(all);
|
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
|
||||||
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
|
|
||||||
org.springframework.data.domain.PageRequest.of(10, 50));
|
|
||||||
|
|
||||||
assertThat(result.items()).isEmpty();
|
|
||||||
assertThat(result.totalElements()).isEqualTo(30L);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── searchDocuments — status filter ─────────────────────────────────────
|
// ─── searchDocuments — status filter ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_passesStatusSpecificationToRepository() {
|
void searchDocuments_passesStatusSpecificationToRepository() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(List.of());
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, UNPAGED);
|
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null);
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(List.of());
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, UNPAGED);
|
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null);
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── getRecentActivity ────────────────────────────────────────────────────
|
// ─── getRecentActivity ────────────────────────────────────────────────────
|
||||||
@@ -1541,10 +1296,10 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(withSender, noSender));
|
.thenReturn(List.of(withSender, noSender));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.documents()).hasSize(2);
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
assertThat(result.documents()).extracting(Document::getTitle).containsExactly("Has Sender", "No Sender");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
||||||
@@ -1561,9 +1316,9 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(noReceivers, withReceiver));
|
.thenReturn(List.of(noReceivers, withReceiver));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null);
|
||||||
|
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
assertThat(result.documents()).extracting(Document::getTitle)
|
||||||
.containsExactly("Has Receiver", "No Receivers");
|
.containsExactly("Has Receiver", "No Receivers");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1583,10 +1338,10 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(docNullName, docSmith));
|
.thenReturn(List.of(docNullName, docSmith));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null);
|
||||||
|
|
||||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
assertThat(result.documents()).extracting(Document::getTitle)
|
||||||
.containsExactly("smith doc", "Null lastname doc");
|
.containsExactly("smith doc", "Null lastname doc");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1605,24 +1360,23 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(1);
|
assertThat(result.matchData()).containsKey(docId);
|
||||||
SearchMatchData md = result.items().get(0).matchData();
|
SearchMatchData md = result.matchData().get(docId);
|
||||||
assertThat(md.titleOffsets()).hasSize(1);
|
assertThat(md.titleOffsets()).hasSize(1);
|
||||||
assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0
|
assertThat(md.titleOffsets().get(0)).isEqualTo(new MatchOffset(0, 5)); // "Brief" = 5 chars at pos 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(List.of());
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, null, null, null,
|
null, null, null, null, null, null, null, null, null, null, null);
|
||||||
UNPAGED);
|
|
||||||
|
|
||||||
assertThat(result.items()).isEmpty();
|
assertThat(result.matchData()).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1639,9 +1393,9 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null);
|
||||||
|
|
||||||
SearchMatchData md = result.items().get(0).matchData();
|
SearchMatchData md = result.matchData().get(docId);
|
||||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||||
assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13
|
assertThat(md.snippetOffsets()).containsExactly(new MatchOffset(13, 5)); // "Brief" at pos 13
|
||||||
}
|
}
|
||||||
@@ -1831,437 +1585,4 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── storeDocumentWithBatchMetadata ──────────────────────────────────────
|
|
||||||
|
|
||||||
private MockMultipartFile pdfFile(String name) {
|
|
||||||
return new MockMultipartFile("file", name, "application/pdf", new byte[]{1});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void stubStoreDocument(String filename) throws Exception {
|
|
||||||
when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.empty());
|
|
||||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void storeDocumentWithBatchMetadata_appliesTitleByIndex() throws Exception {
|
|
||||||
stubStoreDocument("scan01.pdf");
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
|
||||||
meta.setTitles(List.of("Erster Brief", "Zweiter Brief"));
|
|
||||||
|
|
||||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan01.pdf"), meta, 0, null);
|
|
||||||
|
|
||||||
assertThat(result.document().getTitle()).isEqualTo("Erster Brief");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void storeDocumentWithBatchMetadata_resolvesSenderViaPersonService() throws Exception {
|
|
||||||
UUID senderId = UUID.randomUUID();
|
|
||||||
stubStoreDocument("scan02.pdf");
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
Person sender = Person.builder().id(senderId).firstName("Anna").build();
|
|
||||||
when(personService.getById(senderId)).thenReturn(sender);
|
|
||||||
|
|
||||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
|
||||||
meta.setSenderId(senderId);
|
|
||||||
|
|
||||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan02.pdf"), meta, 0, null);
|
|
||||||
|
|
||||||
assertThat(result.document().getSender()).isEqualTo(sender);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void storeDocumentWithBatchMetadata_appliesTagsViaUpdateDocumentTags() throws Exception {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
when(documentRepository.findFirstByOriginalFilename("scan03.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> {
|
|
||||||
Document d = inv.getArgument(0);
|
|
||||||
if (d.getId() == null) d.setId(docId);
|
|
||||||
return d;
|
|
||||||
});
|
|
||||||
when(documentRepository.findById(docId)).thenAnswer(inv -> {
|
|
||||||
Document d = new Document();
|
|
||||||
d.setId(docId);
|
|
||||||
return Optional.of(d);
|
|
||||||
});
|
|
||||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
|
||||||
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
|
||||||
|
|
||||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
|
||||||
meta.setTagNames(List.of("Familie"));
|
|
||||||
|
|
||||||
documentService.storeDocumentWithBatchMetadata(pdfFile("scan03.pdf"), meta, 0, null);
|
|
||||||
|
|
||||||
verify(tagService).findOrCreate("Familie");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void storeDocumentWithBatchMetadata_leavesTitle_whenIndexExceedsTitlesList() throws Exception {
|
|
||||||
stubStoreDocument("scan04.pdf");
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
|
||||||
meta.setTitles(List.of("Only One Title"));
|
|
||||||
|
|
||||||
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan04.pdf"), meta, 5, null);
|
|
||||||
|
|
||||||
assertThat(result.document().getTitle()).isEqualTo("scan04");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── validateBatch ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() {
|
|
||||||
assertThatThrownBy(() -> documentService.validateBatch(51, null))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.hasMessageContaining("50");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() {
|
|
||||||
documentService.validateBatch(50, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() {
|
|
||||||
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO metadata =
|
|
||||||
new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
|
|
||||||
metadata.setTitles(java.util.List.of("A", "B", "C"));
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> documentService.validateBatch(2, metadata))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.hasMessageContaining("titles");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── applyBulkEditToDocument ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
private static org.raddatz.familienarchiv.dto.DocumentBulkEditDTO bulkDto() {
|
|
||||||
return new org.raddatz.familienarchiv.dto.DocumentBulkEditDTO();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_throwsNotFound_whenDocumentMissing() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto(), null))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.hasMessageContaining(id.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_appliesTagsAdditively_preservesExistingTags() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
|
||||||
Tag added = Tag.builder().id(UUID.randomUUID()).name("Kurrent").build();
|
|
||||||
Document doc = Document.builder().id(id).title("T")
|
|
||||||
.tags(new HashSet<>(Set.of(existing)))
|
|
||||||
.receivers(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
when(tagService.findOrCreate("Kurrent")).thenReturn(added);
|
|
||||||
|
|
||||||
var dto = bulkDto();
|
|
||||||
dto.setTagNames(List.of("Kurrent"));
|
|
||||||
documentService.applyBulkEditToDocument(id, dto, null);
|
|
||||||
|
|
||||||
assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_skipsTags_whenTagNamesIsNull() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
|
||||||
Document doc = Document.builder().id(id).title("T")
|
|
||||||
.tags(new HashSet<>(Set.of(existing)))
|
|
||||||
.receivers(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
documentService.applyBulkEditToDocument(id, bulkDto(), null);
|
|
||||||
|
|
||||||
assertThat(doc.getTags()).containsExactly(existing);
|
|
||||||
verify(tagService, never()).findOrCreate(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_skipsTags_whenTagNamesIsEmpty() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
|
|
||||||
Document doc = Document.builder().id(id).title("T")
|
|
||||||
.tags(new HashSet<>(Set.of(existing)))
|
|
||||||
.receivers(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
var dto = bulkDto();
|
|
||||||
dto.setTagNames(List.of());
|
|
||||||
documentService.applyBulkEditToDocument(id, dto, null);
|
|
||||||
|
|
||||||
assertThat(doc.getTags()).containsExactly(existing);
|
|
||||||
verify(tagService, never()).findOrCreate(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_replacesSender_whenSenderIdProvided() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
UUID senderId = UUID.randomUUID();
|
|
||||||
Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
|
||||||
Person newSender = Person.builder().id(senderId).firstName("New").build();
|
|
||||||
Document doc = Document.builder().id(id).title("T")
|
|
||||||
.sender(oldSender)
|
|
||||||
.receivers(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
when(personService.getById(senderId)).thenReturn(newSender);
|
|
||||||
|
|
||||||
var dto = bulkDto();
|
|
||||||
dto.setSenderId(senderId);
|
|
||||||
documentService.applyBulkEditToDocument(id, dto, null);
|
|
||||||
|
|
||||||
assertThat(doc.getSender()).isEqualTo(newSender);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_skipsSender_whenSenderIdIsNull() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("X").build();
|
|
||||||
Document doc = Document.builder().id(id).title("T")
|
|
||||||
.sender(existing)
|
|
||||||
.receivers(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
documentService.applyBulkEditToDocument(id, bulkDto(), null);
|
|
||||||
|
|
||||||
assertThat(doc.getSender()).isEqualTo(existing);
|
|
||||||
verify(personService, never()).getById(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_addsReceiversAdditively_preservesExistingReceivers() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
UUID newReceiverId = UUID.randomUUID();
|
|
||||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
|
||||||
Person added = Person.builder().id(newReceiverId).firstName("New").build();
|
|
||||||
Document doc = Document.builder().id(id).title("T")
|
|
||||||
.receivers(new HashSet<>(Set.of(existing)))
|
|
||||||
.build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
when(personService.getAllById(List.of(newReceiverId))).thenReturn(List.of(added));
|
|
||||||
|
|
||||||
var dto = bulkDto();
|
|
||||||
dto.setReceiverIds(List.of(newReceiverId));
|
|
||||||
documentService.applyBulkEditToDocument(id, dto, null);
|
|
||||||
|
|
||||||
assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_skipsReceivers_whenReceiverIdsIsNullOrEmpty() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
|
|
||||||
Document doc = Document.builder().id(id).title("T")
|
|
||||||
.receivers(new HashSet<>(Set.of(existing)))
|
|
||||||
.build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
var dto = bulkDto();
|
|
||||||
dto.setReceiverIds(List.of());
|
|
||||||
documentService.applyBulkEditToDocument(id, dto, null);
|
|
||||||
|
|
||||||
assertThat(doc.getReceivers()).containsExactly(existing);
|
|
||||||
verify(personService, never()).getAllById(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(documentRepository.save(any())).thenReturn(doc);
|
|
||||||
|
|
||||||
documentService.applyBulkEditToDocument(id, bulkDto(), actorId);
|
|
||||||
|
|
||||||
verify(documentVersionService).recordVersion(doc);
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
eq(AuditKind.METADATA_UPDATED),
|
|
||||||
eq(actorId),
|
|
||||||
eq(id),
|
|
||||||
eq(java.util.Map.of("source", "BULK_EDIT")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(id).title("T")
|
|
||||||
.archiveBox("OldBox")
|
|
||||||
.archiveFolder("OldFolder")
|
|
||||||
.documentLocation("OldLocation")
|
|
||||||
.receivers(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
var dto = bulkDto();
|
|
||||||
dto.setArchiveBox("NewBox");
|
|
||||||
dto.setArchiveFolder("NewFolder");
|
|
||||||
dto.setDocumentLocation("NewLocation");
|
|
||||||
documentService.applyBulkEditToDocument(id, dto, null);
|
|
||||||
|
|
||||||
assertThat(doc.getArchiveBox()).isEqualTo("NewBox");
|
|
||||||
assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder");
|
|
||||||
assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable() {
|
|
||||||
// Sara C1 — unresolvable sender flows up as a per-document error chip
|
|
||||||
// rather than aborting the controller's batch loop.
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
UUID unknownSender = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(personService.getById(unknownSender))
|
|
||||||
.thenThrow(DomainException.notFound(
|
|
||||||
org.raddatz.familienarchiv.exception.ErrorCode.PERSON_NOT_FOUND,
|
|
||||||
"Person not found: " + unknownSender));
|
|
||||||
|
|
||||||
var dto = bulkDto();
|
|
||||||
dto.setSenderId(unknownSender);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, dto, null))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.hasMessageContaining(unknownSender.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── findIdsForFilter ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findIdsForFilter_returnsAllMatchingIds_uncapped() {
|
|
||||||
Document d1 = Document.builder().id(UUID.randomUUID()).title("A").build();
|
|
||||||
Document d2 = Document.builder().id(UUID.randomUUID()).title("B").build();
|
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
|
||||||
.thenReturn(List.of(d1, d2));
|
|
||||||
|
|
||||||
List<UUID> result = documentService.findIdsForFilter(
|
|
||||||
null, null, null, null, null, null, null, null, null);
|
|
||||||
|
|
||||||
assertThat(result).containsExactly(d1.getId(), d2.getId());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec() {
|
|
||||||
// Sara C3 — tagOp=OR flips useOrLogic at the spec layer; without a
|
|
||||||
// test pinning this, a refactor that wired OR to AND (or vice versa)
|
|
||||||
// would slip through.
|
|
||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
|
||||||
.thenReturn(List.of());
|
|
||||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
|
||||||
|
|
||||||
documentService.findIdsForFilter(
|
|
||||||
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR);
|
|
||||||
|
|
||||||
// Spec built without throwing → OR branch was exercised. Coverage gain
|
|
||||||
// is in not-throwing on the OR-specific code path; the actual SQL is
|
|
||||||
// covered by JPA itself.
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
|
|
||||||
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<UUID> result = documentService.findIdsForFilter(
|
|
||||||
"xyz", null, null, null, null, null, null, null, null);
|
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── batchMetadata ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void batchMetadata_returnsEmpty_whenIdsIsNull() {
|
|
||||||
assertThat(documentService.batchMetadata(null)).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void batchMetadata_returnsEmpty_whenIdsIsEmpty() {
|
|
||||||
assertThat(documentService.batchMetadata(List.of())).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void batchMetadata_returnsSummariesWithPdfUrl_forExistingIds() {
|
|
||||||
UUID id1 = UUID.randomUUID();
|
|
||||||
UUID id2 = UUID.randomUUID();
|
|
||||||
Document d1 = Document.builder().id(id1).title("Brief 1").build();
|
|
||||||
Document d2 = Document.builder().id(id2).title("Brief 2").build();
|
|
||||||
when(documentRepository.findAllById(List.of(id1, id2))).thenReturn(List.of(d1, d2));
|
|
||||||
|
|
||||||
var result = documentService.batchMetadata(List.of(id1, id2));
|
|
||||||
|
|
||||||
assertThat(result).hasSize(2);
|
|
||||||
assertThat(result.get(0).id()).isEqualTo(id1);
|
|
||||||
assertThat(result.get(0).title()).isEqualTo("Brief 1");
|
|
||||||
assertThat(result.get(0).pdfUrl()).isEqualTo("/api/documents/" + id1 + "/file");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void batchMetadata_silentlyDropsUnknownIds() {
|
|
||||||
UUID known = UUID.randomUUID();
|
|
||||||
UUID missing = UUID.randomUUID();
|
|
||||||
Document d = Document.builder().id(known).title("Found").build();
|
|
||||||
when(documentRepository.findAllById(List.of(known, missing))).thenReturn(List.of(d));
|
|
||||||
|
|
||||||
var result = documentService.batchMetadata(List.of(known, missing));
|
|
||||||
|
|
||||||
assertThat(result).hasSize(1);
|
|
||||||
assertThat(result.get(0).id()).isEqualTo(known);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void batchMetadata_fallsBackToOriginalFilename_whenTitleIsNull() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document d = Document.builder().id(id).originalFilename("scan001.pdf").build();
|
|
||||||
when(documentRepository.findAllById(List.of(id))).thenReturn(List.of(d));
|
|
||||||
|
|
||||||
var result = documentService.batchMetadata(List.of(id));
|
|
||||||
|
|
||||||
assertThat(result.get(0).title()).isEqualTo("scan001.pdf");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(id).title("T")
|
|
||||||
.archiveBox("KeepBox")
|
|
||||||
.archiveFolder("KeepFolder")
|
|
||||||
.documentLocation("KeepLocation")
|
|
||||||
.receivers(new HashSet<>())
|
|
||||||
.build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
var dto = bulkDto();
|
|
||||||
dto.setArchiveBox(" ");
|
|
||||||
dto.setArchiveFolder("");
|
|
||||||
// documentLocation left null
|
|
||||||
documentService.applyBulkEditToDocument(id, dto, null);
|
|
||||||
|
|
||||||
assertThat(doc.getArchiveBox()).isEqualTo("KeepBox");
|
|
||||||
assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");
|
|
||||||
assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,39 +197,4 @@ class FileServiceTest {
|
|||||||
.isInstanceOf(IOException.class)
|
.isInstanceOf(IOException.class)
|
||||||
.hasMessageContaining("Failed to download");
|
.hasMessageContaining("Failed to download");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── downloadFileStream ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void downloadFileStream_returnsStreamableContent() throws IOException {
|
|
||||||
byte[] content = "streamed bytes".getBytes();
|
|
||||||
GetObjectResponse response = GetObjectResponse.builder().contentType("application/pdf").build();
|
|
||||||
ResponseInputStream<GetObjectResponse> stream = new ResponseInputStream<>(
|
|
||||||
response, AbortableInputStream.create(new ByteArrayInputStream(content)));
|
|
||||||
when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
|
|
||||||
|
|
||||||
try (java.io.InputStream result = fileService.downloadFileStream("documents/file.pdf")) {
|
|
||||||
assertThat(result.readAllBytes()).isEqualTo(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void downloadFileStream_throwsStorageFileNotFoundException_whenNoSuchKey() {
|
|
||||||
NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build();
|
|
||||||
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> fileService.downloadFileStream("missing/key.pdf"))
|
|
||||||
.isInstanceOf(FileService.StorageFileNotFoundException.class)
|
|
||||||
.hasMessageContaining("missing/key.pdf");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void downloadFileStream_throwsIOException_whenS3Exception() {
|
|
||||||
S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build();
|
|
||||||
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> fileService.downloadFileStream("documents/file.pdf"))
|
|
||||||
.isInstanceOf(IOException.class)
|
|
||||||
.hasMessageContaining("Failed to open stream");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,13 +39,12 @@ class MassImportServiceTest {
|
|||||||
@Mock PersonService personService;
|
@Mock PersonService personService;
|
||||||
@Mock TagService tagService;
|
@Mock TagService tagService;
|
||||||
@Mock S3Client s3Client;
|
@Mock S3Client s3Client;
|
||||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
|
||||||
|
|
||||||
MassImportService service;
|
MassImportService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new MassImportService(documentRepository, personService, tagService, s3Client, thumbnailAsyncRunner);
|
service = new MassImportService(documentRepository, personService, tagService, s3Client);
|
||||||
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
|
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
|
||||||
ReflectionTestUtils.setField(service, "colIndex", 0);
|
ReflectionTestUtils.setField(service, "colIndex", 0);
|
||||||
ReflectionTestUtils.setField(service, "colBox", 1);
|
ReflectionTestUtils.setField(service, "colBox", 1);
|
||||||
|
|||||||
@@ -114,43 +114,6 @@ class PersonServiceTest {
|
|||||||
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── personType + title in createPerson(PersonUpdateDTO) ─────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createPerson_dto_persistsPersonType() {
|
|
||||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
|
||||||
dto.setFirstName("Walter"); dto.setLastName("de Gruyter"); dto.setPersonType(PersonType.INSTITUTION);
|
|
||||||
|
|
||||||
Person result = personService.createPerson(dto);
|
|
||||||
|
|
||||||
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createPerson_dto_throwsInvalidPersonType_whenSkip() {
|
|
||||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
|
||||||
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.SKIP);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> personService.createPerson(dto))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
|
||||||
.isEqualTo(400);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createPerson_dto_persistsTitle() {
|
|
||||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
|
||||||
dto.setFirstName("Dr."); dto.setLastName("Müller"); dto.setTitle("Prof."); dto.setPersonType(PersonType.PERSON);
|
|
||||||
|
|
||||||
Person result = personService.createPerson(dto);
|
|
||||||
|
|
||||||
assertThat(result.getTitle()).isEqualTo("Prof.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -182,36 +145,6 @@ class PersonServiceTest {
|
|||||||
.isEqualTo(400);
|
.isEqualTo(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── updatePerson (personType) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updatePerson_throwsInvalidPersonType_whenSkip() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
|
|
||||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
|
||||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.SKIP);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
|
||||||
.isEqualTo(400);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void updatePerson_persistsPersonType() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").personType(PersonType.PERSON).build();
|
|
||||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
|
||||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
|
||||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setPersonType(PersonType.INSTITUTION);
|
|
||||||
|
|
||||||
Person result = personService.updatePerson(id, dto);
|
|
||||||
|
|
||||||
assertThat(result.getPersonType()).isEqualTo(PersonType.INSTITUTION);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
class ThumbnailAsyncRunnerTest {
|
|
||||||
|
|
||||||
private DocumentRepository documentRepository;
|
|
||||||
private ThumbnailService thumbnailService;
|
|
||||||
private ThumbnailAsyncRunner runner;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
documentRepository = mock(DocumentRepository.class);
|
|
||||||
thumbnailService = mock(ThumbnailService.class);
|
|
||||||
runner = new ThumbnailAsyncRunner(documentRepository, thumbnailService);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void dispatchAfterCommit_whenNoTransaction_dispatchesImmediately() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
|
|
||||||
runner.dispatchAfterCommit(id);
|
|
||||||
|
|
||||||
verify(thumbnailService).generate(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void dispatchAfterCommit_whenTransactionActive_registersAfterCommitSynchronization() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
|
|
||||||
TransactionSynchronizationManager.initSynchronization();
|
|
||||||
try {
|
|
||||||
runner.dispatchAfterCommit(id);
|
|
||||||
|
|
||||||
// Nothing fired yet — registered, not executed
|
|
||||||
verify(thumbnailService, never()).generate(any());
|
|
||||||
|
|
||||||
// Simulate commit
|
|
||||||
ArgumentCaptor<TransactionSynchronization> captor =
|
|
||||||
ArgumentCaptor.forClass(TransactionSynchronization.class);
|
|
||||||
assertThat(TransactionSynchronizationManager.getSynchronizations()).hasSize(1);
|
|
||||||
TransactionSynchronizationManager.getSynchronizations().get(0).afterCommit();
|
|
||||||
|
|
||||||
verify(thumbnailService).generate(doc);
|
|
||||||
} finally {
|
|
||||||
TransactionSynchronizationManager.clearSynchronization();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void dispatchAfterCommit_whenRollback_doesNotDispatch() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
|
|
||||||
TransactionSynchronizationManager.initSynchronization();
|
|
||||||
try {
|
|
||||||
runner.dispatchAfterCommit(id);
|
|
||||||
|
|
||||||
// Simulate rollback — afterCompletion with STATUS_ROLLED_BACK, no afterCommit fired
|
|
||||||
TransactionSynchronizationManager.getSynchronizations().get(0)
|
|
||||||
.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
|
|
||||||
|
|
||||||
verify(thumbnailService, never()).generate(any());
|
|
||||||
} finally {
|
|
||||||
TransactionSynchronizationManager.clearSynchronization();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generateAsync_skipsWhenDocumentMissing() {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.empty());
|
|
||||||
|
|
||||||
runner.generateAsync(id);
|
|
||||||
|
|
||||||
verifyNoInteractions(thumbnailService);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generateAsync_timesOutWhenGenerateExceedsLimit() throws Exception {
|
|
||||||
UUID id = UUID.randomUUID();
|
|
||||||
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
|
|
||||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
|
||||||
// generate sleeps longer than the timeout — simulates a hung PDFBox render
|
|
||||||
when(thumbnailService.generate(doc)).thenAnswer(inv -> {
|
|
||||||
Thread.sleep(5_000);
|
|
||||||
return ThumbnailService.Outcome.SUCCESS;
|
|
||||||
});
|
|
||||||
// Shrink timeout for the test
|
|
||||||
ReflectionTestUtils.setField(runner, "generateTimeoutSeconds", 1L);
|
|
||||||
|
|
||||||
long start = System.currentTimeMillis();
|
|
||||||
runner.generateAsync(id);
|
|
||||||
long elapsed = System.currentTimeMillis() - start;
|
|
||||||
|
|
||||||
// Must return before the 5s sleep — within ~2s with timeout=1s plus overhead
|
|
||||||
assertThat(elapsed).isLessThan(3_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
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.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
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.*;
|
|
||||||
|
|
||||||
class ThumbnailBackfillServiceTest {
|
|
||||||
|
|
||||||
private DocumentRepository documentRepository;
|
|
||||||
private ThumbnailService thumbnailService;
|
|
||||||
private ThumbnailBackfillService backfillService;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
documentRepository = mock(DocumentRepository.class);
|
|
||||||
thumbnailService = mock(ThumbnailService.class);
|
|
||||||
backfillService = new ThumbnailBackfillService(documentRepository, thumbnailService);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void initialStatus_isIdle() {
|
|
||||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
|
||||||
|
|
||||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.IDLE);
|
|
||||||
assertThat(status.total()).isZero();
|
|
||||||
assertThat(status.processed()).isZero();
|
|
||||||
assertThat(status.startedAt()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void runBackfillAsync_processesAllDocumentsAndFinishesDone() {
|
|
||||||
Document a = doc();
|
|
||||||
Document b = doc();
|
|
||||||
Document c = doc();
|
|
||||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
|
||||||
.thenReturn(List.of(a, b, c));
|
|
||||||
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
|
|
||||||
backfillService.runBackfillAsync();
|
|
||||||
|
|
||||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
|
||||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
|
||||||
assertThat(status.total()).isEqualTo(3);
|
|
||||||
assertThat(status.processed()).isEqualTo(3);
|
|
||||||
assertThat(status.skipped()).isZero();
|
|
||||||
assertThat(status.failed()).isZero();
|
|
||||||
verify(thumbnailService, times(3)).generate(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void runBackfillAsync_countsSkippedSeparately() {
|
|
||||||
Document a = doc();
|
|
||||||
Document b = doc();
|
|
||||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
|
||||||
.thenReturn(List.of(a, b));
|
|
||||||
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SKIPPED);
|
|
||||||
|
|
||||||
backfillService.runBackfillAsync();
|
|
||||||
|
|
||||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
|
||||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
|
||||||
assertThat(status.processed()).isEqualTo(1);
|
|
||||||
assertThat(status.skipped()).isEqualTo(1);
|
|
||||||
assertThat(status.failed()).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void runBackfillAsync_continuesAfterFailureAndCountsIt() {
|
|
||||||
Document a = doc();
|
|
||||||
Document b = doc();
|
|
||||||
Document c = doc();
|
|
||||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
|
||||||
.thenReturn(List.of(a, b, c));
|
|
||||||
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.FAILED);
|
|
||||||
when(thumbnailService.generate(c)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
|
|
||||||
backfillService.runBackfillAsync();
|
|
||||||
|
|
||||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
|
||||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
|
||||||
assertThat(status.processed()).isEqualTo(2);
|
|
||||||
assertThat(status.failed()).isEqualTo(1);
|
|
||||||
verify(thumbnailService, times(3)).generate(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void runBackfillAsync_continuesWhenServiceThrowsUnexpectedException() {
|
|
||||||
Document a = doc();
|
|
||||||
Document b = doc();
|
|
||||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
|
||||||
.thenReturn(List.of(a, b));
|
|
||||||
when(thumbnailService.generate(a)).thenThrow(new RuntimeException("boom"));
|
|
||||||
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
|
|
||||||
backfillService.runBackfillAsync();
|
|
||||||
|
|
||||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
|
||||||
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
|
|
||||||
assertThat(status.processed()).isEqualTo(1);
|
|
||||||
assertThat(status.failed()).isEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void runBackfillAsync_rejectsConcurrentStart() {
|
|
||||||
// Force state=RUNNING via reflection
|
|
||||||
ThumbnailBackfillService.BackfillStatus running = new ThumbnailBackfillService.BackfillStatus(
|
|
||||||
ThumbnailBackfillService.State.RUNNING, "running", 10, 5, 0, 0, LocalDateTime.now());
|
|
||||||
ReflectionTestUtils.setField(backfillService, "currentStatus", running);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> backfillService.runBackfillAsync())
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
|
||||||
.isEqualTo(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void runBackfillAsync_setsStartedAtAndMessage() {
|
|
||||||
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
|
|
||||||
.thenReturn(List.of(doc()));
|
|
||||||
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
|
|
||||||
LocalDateTime before = LocalDateTime.now().minusSeconds(1);
|
|
||||||
backfillService.runBackfillAsync();
|
|
||||||
|
|
||||||
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
|
|
||||||
assertThat(status.startedAt()).isAfter(before);
|
|
||||||
assertThat(status.message()).isNotBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Document doc() {
|
|
||||||
return Document.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.title("t")
|
|
||||||
.originalFilename("f.pdf")
|
|
||||||
.filePath("documents/f.pdf")
|
|
||||||
.contentType("application/pdf")
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
|
||||||
|
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDPage;
|
|
||||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.context.annotation.Import;
|
|
||||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
|
||||||
import org.springframework.test.context.DynamicPropertySource;
|
|
||||||
import org.testcontainers.containers.GenericContainer;
|
|
||||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
|
||||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
|
||||||
import software.amazon.awssdk.core.sync.RequestBody;
|
|
||||||
import software.amazon.awssdk.regions.Region;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
|
||||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
|
||||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
|
||||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full round-trip integration test against real MinIO and real Postgres. Catches S3
|
|
||||||
* signing / presigning issues that a mocked S3Client would miss — the rest of the
|
|
||||||
* test pyramid mocks at the FileService boundary.
|
|
||||||
*/
|
|
||||||
@SpringBootTest
|
|
||||||
@Import(PostgresContainerConfig.class)
|
|
||||||
class ThumbnailServiceIntegrationTest {
|
|
||||||
|
|
||||||
private static final String BUCKET = "archive-documents";
|
|
||||||
private static final String ACCESS_KEY = "minioadmin";
|
|
||||||
private static final String SECRET_KEY = "minioadmin";
|
|
||||||
|
|
||||||
static GenericContainer<?> minio = new GenericContainer<>("minio/minio:RELEASE.2024-06-13T22-53-53Z")
|
|
||||||
.withEnv("MINIO_ROOT_USER", ACCESS_KEY)
|
|
||||||
.withEnv("MINIO_ROOT_PASSWORD", SECRET_KEY)
|
|
||||||
.withCommand("server /data")
|
|
||||||
.withExposedPorts(9000);
|
|
||||||
|
|
||||||
static {
|
|
||||||
minio.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
@DynamicPropertySource
|
|
||||||
static void s3Properties(DynamicPropertyRegistry registry) {
|
|
||||||
registry.add("app.s3.endpoint", () -> "http://" + minio.getHost() + ":" + minio.getMappedPort(9000));
|
|
||||||
registry.add("app.s3.access-key", () -> ACCESS_KEY);
|
|
||||||
registry.add("app.s3.secret-key", () -> SECRET_KEY);
|
|
||||||
registry.add("app.s3.bucket", () -> BUCKET);
|
|
||||||
registry.add("app.s3.region", () -> "eu-central-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Autowired S3Client s3Client;
|
|
||||||
@Autowired ThumbnailService thumbnailService;
|
|
||||||
@Autowired DocumentRepository documentRepository;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_writesDecodableJpegToMinio_readbackMatches() throws IOException {
|
|
||||||
// Ensure bucket exists (the real app has a bootstrap container for this; in tests we do it here).
|
|
||||||
// Re-creating is a no-op; wrap in try/catch because the SDK throws on "already owned".
|
|
||||||
try (S3Client bootstrap = buildClient()) {
|
|
||||||
try {
|
|
||||||
bootstrap.createBucket(CreateBucketRequest.builder().bucket(BUCKET).build());
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// already exists
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist first so Hibernate assigns the UUID — avoids StaleObjectState on a pre-set id
|
|
||||||
Document persisted = documentRepository.save(Document.builder()
|
|
||||||
.title("IT Doc")
|
|
||||||
.originalFilename("test.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.contentType("application/pdf")
|
|
||||||
.build());
|
|
||||||
UUID docId = persisted.getId();
|
|
||||||
String pdfKey = "documents/" + docId + "_test.pdf";
|
|
||||||
|
|
||||||
s3Client.putObject(PutObjectRequest.builder()
|
|
||||||
.bucket(BUCKET)
|
|
||||||
.key(pdfKey)
|
|
||||||
.contentType("application/pdf")
|
|
||||||
.build(),
|
|
||||||
RequestBody.fromBytes(createSamplePdf()));
|
|
||||||
|
|
||||||
persisted.setFilePath(pdfKey);
|
|
||||||
persisted = documentRepository.save(persisted);
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(persisted);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
|
|
||||||
Document reloaded = documentRepository.findById(docId).orElseThrow();
|
|
||||||
assertThat(reloaded.getThumbnailKey()).isEqualTo("thumbnails/" + docId + ".jpg");
|
|
||||||
assertThat(reloaded.getThumbnailGeneratedAt()).isNotNull();
|
|
||||||
|
|
||||||
// Read back from MinIO and verify it decodes as a JPEG of the expected width
|
|
||||||
try (InputStream in = s3Client.getObject(GetObjectRequest.builder()
|
|
||||||
.bucket(BUCKET).key(reloaded.getThumbnailKey()).build())) {
|
|
||||||
byte[] jpegBytes = in.readAllBytes();
|
|
||||||
BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(jpegBytes));
|
|
||||||
assertThat(decoded).isNotNull();
|
|
||||||
assertThat(decoded.getWidth()).isEqualTo(240);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static S3Client buildClient() {
|
|
||||||
return S3Client.builder()
|
|
||||||
.endpointOverride(URI.create("http://" + minio.getHost() + ":" + minio.getMappedPort(9000)))
|
|
||||||
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
|
|
||||||
.region(Region.of("eu-central-1"))
|
|
||||||
.credentialsProvider(StaticCredentialsProvider.create(
|
|
||||||
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] createSamplePdf() throws IOException {
|
|
||||||
try (PDDocument pdf = new PDDocument()) {
|
|
||||||
pdf.addPage(new PDPage(PDRectangle.A4));
|
|
||||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
|
||||||
pdf.save(bos);
|
|
||||||
return bos.toByteArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
|
||||||
|
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDPage;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
|
||||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
|
||||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
|
||||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
|
||||||
import org.raddatz.familienarchiv.model.ThumbnailAspect;
|
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
import software.amazon.awssdk.core.sync.RequestBody;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
|
||||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import java.awt.Color;
|
|
||||||
import java.awt.Graphics2D;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.Mockito.*;
|
|
||||||
|
|
||||||
class ThumbnailServiceTest {
|
|
||||||
|
|
||||||
private FileService fileService;
|
|
||||||
private S3Client s3Client;
|
|
||||||
private DocumentRepository documentRepository;
|
|
||||||
private ThumbnailService thumbnailService;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
fileService = mock(FileService.class);
|
|
||||||
s3Client = mock(S3Client.class);
|
|
||||||
documentRepository = mock(DocumentRepository.class);
|
|
||||||
thumbnailService = new ThumbnailService(fileService, s3Client, documentRepository);
|
|
||||||
ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket");
|
|
||||||
when(documentRepository.save(any(Document.class))).thenAnswer(i -> i.getArgument(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_returnsSkipped_whenDocumentHasNoFilePath() {
|
|
||||||
Document doc = makeDoc("application/pdf", null);
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED);
|
|
||||||
verifyNoInteractions(s3Client);
|
|
||||||
assertThat(doc.getThumbnailKey()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_returnsSkipped_forUnsupportedContentType() throws IOException {
|
|
||||||
Document doc = makeDoc("application/msword", "documents/letter.doc");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(new byte[]{1, 2, 3}));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SKIPPED);
|
|
||||||
verifyNoInteractions(s3Client);
|
|
||||||
assertThat(doc.getThumbnailKey()).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_rendersPdf_uploadsJpeg_updatesEntity() throws IOException {
|
|
||||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
|
||||||
byte[] pdfBytes = createSamplePdf();
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(pdfBytes));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
|
|
||||||
ArgumentCaptor<PutObjectRequest> putCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
|
|
||||||
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
|
|
||||||
verify(s3Client).putObject(putCaptor.capture(), bodyCaptor.capture());
|
|
||||||
|
|
||||||
PutObjectRequest req = putCaptor.getValue();
|
|
||||||
assertThat(req.bucket()).isEqualTo("test-bucket");
|
|
||||||
assertThat(req.key()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
|
|
||||||
assertThat(req.contentType()).isEqualTo("image/jpeg");
|
|
||||||
|
|
||||||
byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream());
|
|
||||||
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded));
|
|
||||||
assertThat(jpg).isNotNull();
|
|
||||||
assertThat(jpg.getWidth()).isEqualTo(240);
|
|
||||||
|
|
||||||
assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
|
|
||||||
assertThat(doc.getThumbnailGeneratedAt()).isNotNull();
|
|
||||||
verify(documentRepository).save(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_rendersPng_uploadsJpegAtWidth240() throws IOException {
|
|
||||||
Document doc = makeDoc("image/png", "documents/scan.png");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
|
|
||||||
verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
|
|
||||||
byte[] uploaded = readAll(bodyCaptor.getValue().contentStreamProvider().newStream());
|
|
||||||
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(uploaded));
|
|
||||||
assertThat(jpg.getWidth()).isEqualTo(240);
|
|
||||||
assertThat(jpg.getHeight()).isEqualTo(320); // 600x800 -> 240x320
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_rendersJpeg_uploadsScaledJpeg() throws IOException {
|
|
||||||
Document doc = makeDoc("image/jpeg", "documents/photo.jpg");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400)));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
ArgumentCaptor<RequestBody> bodyCaptor = ArgumentCaptor.forClass(RequestBody.class);
|
|
||||||
verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
|
|
||||||
BufferedImage jpg = ImageIO.read(new ByteArrayInputStream(
|
|
||||||
readAll(bodyCaptor.getValue().contentStreamProvider().newStream())));
|
|
||||||
assertThat(jpg.getWidth()).isEqualTo(240);
|
|
||||||
assertThat(jpg.getHeight()).isEqualTo(120); // 800x400 -> 240x120
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_returnsFailed_whenS3PutThrows() throws IOException {
|
|
||||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(createSamplePdf()));
|
|
||||||
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
|
|
||||||
.thenThrow((S3Exception) S3Exception.builder().message("quota exceeded").statusCode(507).build());
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
|
||||||
assertThat(doc.getThumbnailKey()).isNull();
|
|
||||||
verify(documentRepository, never()).save(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_returnsFailed_whenSourceStreamThrows() throws IOException {
|
|
||||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenThrow(new IOException("network blip"));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
|
||||||
verifyNoInteractions(s3Client);
|
|
||||||
verify(documentRepository, never()).save(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_persistsPageCount_ofOne_forSingleImageUpload() throws IOException {
|
|
||||||
// Image uploads are always a single page from the user's perspective.
|
|
||||||
Document doc = makeDoc("image/png", "documents/scan.png");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
assertThat(doc.getPageCount()).isEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_persistsPageCount_fromPdfDocument() throws IOException {
|
|
||||||
Document doc = makeDoc("application/pdf", "documents/multi.pdf");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(createSamplePdf(3)));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
assertThat(doc.getPageCount()).isEqualTo(3);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException {
|
|
||||||
// 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT.
|
|
||||||
Document doc = makeDoc("image/png", "documents/portrait.png");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_persistsLandscapeAspect_whenWidthIsWellAboveHeight() throws IOException {
|
|
||||||
// 800x400 → ratio 2.0 → clearly above 1.1 → LANDSCAPE.
|
|
||||||
Document doc = makeDoc("image/jpeg", "documents/wide.jpg");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400)));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_persistsPortraitAspect_whenSquareImage_belowLandscapeThreshold() throws IOException {
|
|
||||||
// 500x500 → ratio 1.0 → below 1.1 threshold → PORTRAIT (A4 scans often
|
|
||||||
// come in at near-square and we want them to live in the portrait tile).
|
|
||||||
Document doc = makeDoc("image/png", "documents/square.png");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(createSamplePng(500, 500)));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_persistsPortraitAspect_justUnderLandscapeThreshold() throws IOException {
|
|
||||||
// 1099x1000 → ratio 1.099 → just under 1.1 threshold → PORTRAIT.
|
|
||||||
Document doc = makeDoc("image/png", "documents/near_threshold.png");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(createSamplePng(1099, 1000)));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
|
|
||||||
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_returnsFailed_whenImageBytesAreCorrupt() throws IOException {
|
|
||||||
// Truncated JPEG header — ImageIO returns null rather than throwing.
|
|
||||||
// Without the corrupt-image guard this would later NPE inside the aspect /
|
|
||||||
// dimension computation in scaleToWidth.
|
|
||||||
Document doc = makeDoc("image/jpeg", "documents/corrupt.jpg");
|
|
||||||
byte[] truncated = new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0};
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(truncated));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
|
||||||
verifyNoInteractions(s3Client);
|
|
||||||
verify(documentRepository, never()).save(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_returnsFailed_whenPdfBytesAreCorrupt() throws IOException {
|
|
||||||
// "PDF" header but no body — PDFBox throws IOException while loading.
|
|
||||||
Document doc = makeDoc("application/pdf", "documents/corrupt.pdf");
|
|
||||||
byte[] fakePdf = "%PDF-1.4\n".getBytes();
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(fakePdf));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
|
||||||
verifyNoInteractions(s3Client);
|
|
||||||
verify(documentRepository, never()).save(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException {
|
|
||||||
// Covers the "orphan thumbnail" edge case: S3 upload succeeded but the
|
|
||||||
// entity update blew up. We must still return FAILED so the backfill
|
|
||||||
// tally is honest, without losing the fact that we already put bytes in S3.
|
|
||||||
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
|
|
||||||
when(fileService.downloadFileStream(anyString()))
|
|
||||||
.thenReturn(new ByteArrayInputStream(createSamplePdf()));
|
|
||||||
when(documentRepository.save(any()))
|
|
||||||
.thenThrow(new RuntimeException("constraint violation"));
|
|
||||||
|
|
||||||
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
|
|
||||||
|
|
||||||
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
|
|
||||||
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
|
||||||
verify(documentRepository).save(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private Document makeDoc(String contentType, String filePath) {
|
|
||||||
Document doc = Document.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.title("Test Doc")
|
|
||||||
.originalFilename("test.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.contentType(contentType)
|
|
||||||
.filePath(filePath)
|
|
||||||
.build();
|
|
||||||
doc.setCreatedAt(LocalDateTime.now());
|
|
||||||
doc.setUpdatedAt(LocalDateTime.now());
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] createSamplePdf() throws IOException {
|
|
||||||
return createSamplePdf(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] createSamplePdf(int pageCount) throws IOException {
|
|
||||||
try (PDDocument doc = new PDDocument()) {
|
|
||||||
for (int i = 0; i < pageCount; i++) {
|
|
||||||
PDPage page = new PDPage(PDRectangle.A4);
|
|
||||||
doc.addPage(page);
|
|
||||||
try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
|
|
||||||
content.beginText();
|
|
||||||
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24);
|
|
||||||
content.newLineAtOffset(100, 700);
|
|
||||||
content.showText("Lieber Hans,");
|
|
||||||
content.endText();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
|
||||||
doc.save(bos);
|
|
||||||
return bos.toByteArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] createSamplePng(int width, int height) throws IOException {
|
|
||||||
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
|
||||||
Graphics2D g = img.createGraphics();
|
|
||||||
g.setColor(Color.LIGHT_GRAY);
|
|
||||||
g.fillRect(0, 0, width, height);
|
|
||||||
g.setColor(Color.DARK_GRAY);
|
|
||||||
g.fillRect(0, 0, width, height / 4);
|
|
||||||
g.dispose();
|
|
||||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
|
||||||
ImageIO.write(img, "png", bos);
|
|
||||||
return bos.toByteArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] createSampleJpeg(int width, int height) throws IOException {
|
|
||||||
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
|
||||||
Graphics2D g = img.createGraphics();
|
|
||||||
g.setColor(Color.WHITE);
|
|
||||||
g.fillRect(0, 0, width, height);
|
|
||||||
g.dispose();
|
|
||||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
|
||||||
ImageIO.write(img, "jpg", bos);
|
|
||||||
return bos.toByteArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] readAll(InputStream stream) throws IOException {
|
|
||||||
try (stream) {
|
|
||||||
return stream.readAllBytes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
|
||||||
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
@@ -16,25 +13,17 @@ import org.raddatz.familienarchiv.repository.TranscriptionWeeklyStatsProjection;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class TranscriptionQueueServiceTest {
|
class TranscriptionQueueServiceTest {
|
||||||
|
|
||||||
@Mock DocumentRepository documentRepository;
|
@Mock DocumentRepository documentRepository;
|
||||||
@Mock AuditLogQueryService auditLogQueryService;
|
|
||||||
@InjectMocks TranscriptionQueueService service;
|
@InjectMocks TranscriptionQueueService service;
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void stubContributors() {
|
|
||||||
lenient().when(auditLogQueryService.findContributorsPerDocument(any())).thenReturn(Map.of());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── getSegmentationQueue ─────────────────────────────────────────────────
|
// ─── getSegmentationQueue ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -53,38 +42,6 @@ class TranscriptionQueueServiceTest {
|
|||||||
assertThat(result.get(0).annotationCount()).isEqualTo(0);
|
assertThat(result.get(0).annotationCount()).isEqualTo(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void getSegmentationQueue_returnsEmptyList_whenQueueIsEmpty() {
|
|
||||||
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
|
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
verifyNoInteractions(auditLogQueryService);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getSegmentationQueue_returnsAllFive_andHasMoreFalse_whenExactlyFiveContributors() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
|
|
||||||
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
|
|
||||||
|
|
||||||
List<ActivityActorDTO> fiveActors = List.of(
|
|
||||||
new ActivityActorDTO("A1", "#111", "Alice One"),
|
|
||||||
new ActivityActorDTO("A2", "#222", "Alice Two"),
|
|
||||||
new ActivityActorDTO("A3", "#333", "Alice Three"),
|
|
||||||
new ActivityActorDTO("A4", "#444", "Alice Four"),
|
|
||||||
new ActivityActorDTO("A5", "#555", "Alice Five")
|
|
||||||
);
|
|
||||||
when(auditLogQueryService.findContributorsPerDocument(List.of(docId)))
|
|
||||||
.thenReturn(Map.of(docId, fiveActors));
|
|
||||||
|
|
||||||
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
|
|
||||||
|
|
||||||
assertThat(result.get(0).contributors()).hasSize(5);
|
|
||||||
assertThat(result.get(0).hasMoreContributors()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getSegmentationQueue_mapsDocumentDateWhenPresent() {
|
void getSegmentationQueue_mapsDocumentDateWhenPresent() {
|
||||||
LocalDate date = LocalDate.of(1920, 6, 15);
|
LocalDate date = LocalDate.of(1920, 6, 15);
|
||||||
@@ -151,47 +108,6 @@ class TranscriptionQueueServiceTest {
|
|||||||
assertThat(result.transcriptionCount()).isEqualTo(0L);
|
assertThat(result.transcriptionCount()).isEqualTo(0L);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── contributors ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getSegmentationQueue_includesContributors_whenAuditDataPresent() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
|
|
||||||
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
|
|
||||||
|
|
||||||
ActivityActorDTO actor = new ActivityActorDTO("MR", "#a6dad8", "Max Raddatz");
|
|
||||||
when(auditLogQueryService.findContributorsPerDocument(List.of(docId)))
|
|
||||||
.thenReturn(Map.of(docId, List.of(actor)));
|
|
||||||
|
|
||||||
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
|
|
||||||
|
|
||||||
assertThat(result.get(0).contributors()).containsExactly(actor);
|
|
||||||
assertThat(result.get(0).hasMoreContributors()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getSegmentationQueue_capsContributorsAtFive_andSetsHasMoreFlag() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
|
|
||||||
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj));
|
|
||||||
|
|
||||||
List<ActivityActorDTO> sixActors = List.of(
|
|
||||||
new ActivityActorDTO("A1", "#111", "Alice One"),
|
|
||||||
new ActivityActorDTO("A2", "#222", "Alice Two"),
|
|
||||||
new ActivityActorDTO("A3", "#333", "Alice Three"),
|
|
||||||
new ActivityActorDTO("A4", "#444", "Alice Four"),
|
|
||||||
new ActivityActorDTO("A5", "#555", "Alice Five"),
|
|
||||||
new ActivityActorDTO("A6", "#666", "Alice Six")
|
|
||||||
);
|
|
||||||
when(auditLogQueryService.findContributorsPerDocument(List.of(docId)))
|
|
||||||
.thenReturn(Map.of(docId, sixActors));
|
|
||||||
|
|
||||||
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
|
|
||||||
|
|
||||||
assertThat(result.get(0).contributors()).hasSize(5);
|
|
||||||
assertThat(result.get(0).hasMoreContributors()).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private TranscriptionQueueProjection mockQueueProjection(
|
private TranscriptionQueueProjection mockQueueProjection(
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ package org.raddatz.familienarchiv.service;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
|
||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
|
||||||
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
@@ -37,7 +34,6 @@ class UserServiceTest {
|
|||||||
@Mock AppUserRepository userRepository;
|
@Mock AppUserRepository userRepository;
|
||||||
@Mock UserGroupRepository groupRepository;
|
@Mock UserGroupRepository groupRepository;
|
||||||
@Mock PasswordEncoder passwordEncoder;
|
@Mock PasswordEncoder passwordEncoder;
|
||||||
@Mock AuditService auditService;
|
|
||||||
@InjectMocks UserService userService;
|
@InjectMocks UserService userService;
|
||||||
|
|
||||||
// ─── findByEmail ──────────────────────────────────────────────────────────
|
// ─── findByEmail ──────────────────────────────────────────────────────────
|
||||||
@@ -65,7 +61,7 @@ class UserServiceTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
when(userRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.deleteUser(UUID.randomUUID(), id))
|
assertThatThrownBy(() -> userService.deleteUser(id))
|
||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +71,7 @@ class UserServiceTest {
|
|||||||
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
AppUser user = AppUser.builder().id(id).email("gast@example.com").build();
|
||||||
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
userService.deleteUser(UUID.randomUUID(), id);
|
userService.deleteUser(id);
|
||||||
|
|
||||||
verify(userRepository).delete(user);
|
verify(userRepository).delete(user);
|
||||||
}
|
}
|
||||||
@@ -94,7 +90,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
AppUser result = userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(userRepository).save(any());
|
verify(userRepository).save(any());
|
||||||
@@ -112,7 +108,7 @@ class UserServiceTest {
|
|||||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
||||||
when(userRepository.save(any())).thenReturn(existing);
|
when(userRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
verify(userRepository, times(1)).save(existing);
|
verify(userRepository, times(1)).save(existing);
|
||||||
}
|
}
|
||||||
@@ -233,7 +229,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getFirstName()).isEqualTo("Ada");
|
assertThat(result.getFirstName()).isEqualTo("Ada");
|
||||||
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
||||||
@@ -250,7 +246,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setFirstName("Ada");
|
dto.setFirstName("Ada");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).containsExactly(adminGroup);
|
assertThat(result.getGroups()).containsExactly(adminGroup);
|
||||||
}
|
}
|
||||||
@@ -268,7 +264,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setGroupIds(List.of(newGroup.getId()));
|
dto.setGroupIds(List.of(newGroup.getId()));
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).containsExactly(newGroup);
|
assertThat(result.getGroups()).containsExactly(newGroup);
|
||||||
}
|
}
|
||||||
@@ -285,7 +281,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setGroupIds(List.of());
|
dto.setGroupIds(List.of());
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getGroups()).isEmpty();
|
assertThat(result.getGroups()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -317,7 +313,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
AppUser result = userService.createUserOrUpdate(UUID.randomUUID(), req);
|
AppUser result = userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(groupRepository).findAllById(List.of(group.getId()));
|
verify(groupRepository).findAllById(List.of(group.getId()));
|
||||||
@@ -382,7 +378,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setNewPassword("newSecret");
|
dto.setNewPassword("newSecret");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getPassword()).isEqualTo("newHashed");
|
assertThat(result.getPassword()).isEqualTo("newHashed");
|
||||||
}
|
}
|
||||||
@@ -397,7 +393,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setNewPassword(" ");
|
dto.setNewPassword(" ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getPassword()).isEqualTo("original");
|
assertThat(result.getPassword()).isEqualTo("original");
|
||||||
verify(passwordEncoder, never()).encode(any());
|
verify(passwordEncoder, never()).encode(any());
|
||||||
@@ -412,7 +408,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail(" ");
|
dto.setEmail(" ");
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining("blank");
|
.hasMessageContaining("blank");
|
||||||
}
|
}
|
||||||
@@ -429,7 +425,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail("taken@example.com");
|
dto.setEmail("taken@example.com");
|
||||||
|
|
||||||
assertThatThrownBy(() -> userService.adminUpdateUser(UUID.randomUUID(), id, dto))
|
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining("E-Mail");
|
.hasMessageContaining("E-Mail");
|
||||||
}
|
}
|
||||||
@@ -501,7 +497,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
verify(groupRepository, never()).findAllById(any());
|
verify(groupRepository, never()).findAllById(any());
|
||||||
}
|
}
|
||||||
@@ -565,7 +561,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(null);
|
dto.setContact(null);
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isNull();
|
assertThat(result.getContact()).isNull();
|
||||||
}
|
}
|
||||||
@@ -580,7 +576,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(" ");
|
dto.setContact(" ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isNull();
|
assertThat(result.getContact()).isNull();
|
||||||
}
|
}
|
||||||
@@ -595,7 +591,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setContact(" phone: 555 ");
|
dto.setContact(" phone: 555 ");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getContact()).isEqualTo("phone: 555");
|
assertThat(result.getContact()).isEqualTo("phone: 555");
|
||||||
}
|
}
|
||||||
@@ -610,7 +606,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail(null);
|
dto.setEmail(null);
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
||||||
}
|
}
|
||||||
@@ -626,7 +622,7 @@ class UserServiceTest {
|
|||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
dto.setEmail("me@example.com");
|
dto.setEmail("me@example.com");
|
||||||
|
|
||||||
AppUser result = userService.adminUpdateUser(UUID.randomUUID(), id, dto);
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +640,7 @@ class UserServiceTest {
|
|||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("ng@example.com").build();
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
userService.createUserOrUpdate(UUID.randomUUID(), req);
|
userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
verify(groupRepository, never()).findAllById(any());
|
verify(groupRepository, never()).findAllById(any());
|
||||||
}
|
}
|
||||||
@@ -703,160 +699,6 @@ class UserServiceTest {
|
|||||||
assertThat(result).containsExactly(g);
|
assertThat(result).containsExactly(g);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── audit: GROUP_MEMBERSHIP_CHANGED ─────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminUpdateUser_logsGroupMembershipChanged_whenGroupSetChanges() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").permissions(Set.of("READ_ALL")).build();
|
|
||||||
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").permissions(Set.of("WRITE_ALL")).build();
|
|
||||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(oldGroup)).build();
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup));
|
|
||||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
|
||||||
dto.setGroupIds(List.of(newGroup.getId()));
|
|
||||||
|
|
||||||
userService.adminUpdateUser(actorId, userId, dto);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
org.mockito.ArgumentMatchers.eq(AuditKind.GROUP_MEMBERSHIP_CHANGED),
|
|
||||||
org.mockito.ArgumentMatchers.eq(actorId),
|
|
||||||
org.mockito.ArgumentMatchers.isNull(),
|
|
||||||
payloadCaptor.capture());
|
|
||||||
java.util.Map<String, Object> payload = payloadCaptor.getValue();
|
|
||||||
assertThat(payload).containsEntry("email", "u@example.com");
|
|
||||||
assertThat((java.util.List<String>) payload.get("addedGroups")).containsExactly("Editors");
|
|
||||||
assertThat((java.util.List<String>) payload.get("removedGroups")).containsExactly("Viewers");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupsUnchanged() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
|
||||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
|
|
||||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
|
||||||
dto.setGroupIds(List.of(group.getId()));
|
|
||||||
|
|
||||||
userService.adminUpdateUser(actorId, userId, dto);
|
|
||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminUpdateUser_doesNotLogGroupMembershipChanged_whenGroupIdsIsNull() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
|
||||||
AppUser user = AppUser.builder().id(userId).email("u@example.com").groups(Set.of(group)).build();
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
|
||||||
// groupIds not set → null
|
|
||||||
|
|
||||||
userService.adminUpdateUser(actorId, userId, dto);
|
|
||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── audit: USER_DELETED ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deleteUser_logsUserDeleted_withEmailInPayload() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
UUID userId = UUID.randomUUID();
|
|
||||||
AppUser user = AppUser.builder().id(userId).email("gone@example.com").build();
|
|
||||||
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
|
|
||||||
|
|
||||||
userService.deleteUser(actorId, userId);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
org.mockito.ArgumentMatchers.eq(AuditKind.USER_DELETED),
|
|
||||||
org.mockito.ArgumentMatchers.eq(actorId),
|
|
||||||
org.mockito.ArgumentMatchers.isNull(),
|
|
||||||
payloadCaptor.capture());
|
|
||||||
assertThat(payloadCaptor.getValue()).containsEntry("email", "gone@example.com");
|
|
||||||
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── audit: USER_CREATED ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUserOrUpdate_logsUserCreated_whenUserIsNew() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
CreateUserRequest req = new CreateUserRequest();
|
|
||||||
req.setEmail("new@example.com");
|
|
||||||
req.setInitialPassword("secret");
|
|
||||||
req.setGroupIds(List.of());
|
|
||||||
|
|
||||||
when(userRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
|
|
||||||
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
|
||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("new@example.com").build();
|
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
userService.createUserOrUpdate(actorId, req);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
ArgumentCaptor<java.util.Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(java.util.Map.class);
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
org.mockito.ArgumentMatchers.eq(AuditKind.USER_CREATED),
|
|
||||||
org.mockito.ArgumentMatchers.eq(actorId),
|
|
||||||
org.mockito.ArgumentMatchers.isNull(),
|
|
||||||
payloadCaptor.capture());
|
|
||||||
assertThat(payloadCaptor.getValue()).containsKey("userId");
|
|
||||||
assertThat(payloadCaptor.getValue()).containsEntry("email", "new@example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUserOrUpdate_doesNotLogUserCreated_whenUserAlreadyExists() {
|
|
||||||
UUID actorId = UUID.randomUUID();
|
|
||||||
CreateUserRequest req = new CreateUserRequest();
|
|
||||||
req.setEmail("existing@example.com");
|
|
||||||
req.setInitialPassword("pass");
|
|
||||||
req.setGroupIds(List.of());
|
|
||||||
|
|
||||||
AppUser existing = AppUser.builder().id(UUID.randomUUID()).email("existing@example.com").build();
|
|
||||||
when(userRepository.findByEmail("existing@example.com")).thenReturn(Optional.of(existing));
|
|
||||||
when(passwordEncoder.encode(any())).thenReturn("encoded");
|
|
||||||
when(userRepository.save(any())).thenReturn(existing);
|
|
||||||
|
|
||||||
userService.createUserOrUpdate(actorId, req);
|
|
||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── createUserForBootstrap ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createUserForBootstrap_createsUserWithoutAuditEvent() {
|
|
||||||
CreateUserRequest req = new CreateUserRequest();
|
|
||||||
req.setEmail("bootstrap@example.com");
|
|
||||||
req.setInitialPassword("secret");
|
|
||||||
req.setGroupIds(List.of());
|
|
||||||
|
|
||||||
when(userRepository.findByEmail("bootstrap@example.com")).thenReturn(Optional.empty());
|
|
||||||
when(passwordEncoder.encode("secret")).thenReturn("encoded");
|
|
||||||
AppUser saved = AppUser.builder().id(UUID.randomUUID()).email("bootstrap@example.com").build();
|
|
||||||
when(userRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
AppUser result = userService.createUserForBootstrap(req);
|
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
|
||||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── createGroup ──────────────────────────────────────────────────────────
|
// ─── createGroup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
# ADR-003: Session-Rollup Unified Activity Feed on `/chronik`
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The app had two disconnected ways to see what was happening in the archive:
|
|
||||||
|
|
||||||
1. `/notifications` — personal mentions/replies only, delivered via the `notifications` table and a Bell dropdown.
|
|
||||||
2. Dashboard activity feed — ambient events (uploads, transcription, annotations, comments, mentions) via `/api/dashboard/activity`, which deduplicated using `DISTINCT ON (actor_id, document_id, kind, date_trunc('hour', happened_at))`.
|
|
||||||
|
|
||||||
Two separate lists was a poor mental model (personal vs. ambient feel the same to the user), the `/notifications` page wasted horizontal space, the dashboard's "Alle anzeigen" pointed to `/documents` (dead-end), and the hour-trunc dedupe produced ugly splits on natural sessions — saving 20 transcription blocks at 08:58, 08:59, 09:01 yielded two rows.
|
|
||||||
|
|
||||||
We needed one page that merges both streams, keeps personal mentions visually loud, and aggregates ambient noise coherently.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**One page `/chronik` backed by two endpoints.** The SvelteKit `+page.server.ts` composes data from `/api/dashboard/activity` (for the ambient timeline) and `/api/notifications` (for the "Für dich" box). No new `/api/chronik` orchestrator — the frontend load function is the composition seam.
|
|
||||||
|
|
||||||
**Session-style rollup replaces hour-trunc dedupe everywhere.** `AuditLogQueryRepository.findDedupedActivityFeed` is renamed to `findRolledUpActivityFeed` and rewritten using a `LAG()`-based session algorithm:
|
|
||||||
|
|
||||||
```
|
|
||||||
LAG(happened_at) OVER (PARTITION BY actor_id, document_id, kind ORDER BY happened_at)
|
|
||||||
→ is_new_session = gap > 7200s (or first event in partition, or kind ∈ {COMMENT_ADDED, MENTION_CREATED})
|
|
||||||
→ SUM(is_new_session) OVER (... ROWS UNBOUNDED PRECEDING) = session_id
|
|
||||||
→ GROUP BY (actor_id, document_id, kind, session_id) → MIN(happened_at), MAX(...), COUNT(*)
|
|
||||||
```
|
|
||||||
|
|
||||||
Events within 120 min on the same `(actor, document, kind)` become one row with `count` and `happenedAtUntil` fields. `COMMENT_ADDED` and `MENTION_CREATED` always start a new session — these kinds never roll up. No hard cap on total session span (a 4-hour transcription sitting is one row). The hour-trunc dedupe SQL is **deleted**, not kept alongside — one aggregation strategy per query.
|
|
||||||
|
|
||||||
**URL is universal German `/chronik` across all locales**, matching the existing convention (`/dokumente`, `/personen`, `/briefwechsel`). Content is translated via Paraglide; the URL is a stable German identifier, not a translatable route.
|
|
||||||
|
|
||||||
**DTO extended, not replaced.** `ActivityFeedItemDTO` gains `count: int` (required, `1` for singletons) and `happenedAtUntil: OffsetDateTime?` (null for singletons, end-of-session for rollups). One DTO shape serves both the Chronik timeline and the dashboard side-rail.
|
|
||||||
|
|
||||||
**`/notifications` route is deleted outright.** The app is pre-production — no 301 redirect, no zombie page.
|
|
||||||
|
|
||||||
## Alternatives Considered
|
|
||||||
|
|
||||||
| Alternative | Why rejected |
|
|
||||||
|---|---|
|
|
||||||
| Fixed 2-hour wall-clock buckets (`date_trunc('hour', happened_at / 2)`) | Splits natural sessions at bucket boundaries (e.g. events at 13:58 / 13:59 / 14:01 land in two rollup rows) |
|
|
||||||
| Keep `DISTINCT ON hour-trunc` alongside new rollup query | Two aggregation strategies = zombie logic; dashboard and Chronik would drift |
|
|
||||||
| New `/api/chronik` endpoint that merges both streams | Couples two domains (notifications + audit) at the API layer; composition belongs in `+page.server.ts` |
|
|
||||||
| Localized URL slugs (`/chronik` / `/chronicle` / `/crónica`) | Breaks the project's existing German-URL convention and adds Paraglide routing overhead for zero UX value |
|
|
||||||
| Per-locale rollup in the SQL (e.g. align to local-day boundaries) | Timezone-aware SQL is brittle; rollup is a time-gap concept, not a calendar-day concept |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**Easier:**
|
|
||||||
- One hot path — `/api/dashboard/activity` is backed by a single partial covering index (`V49__add_audit_log_rollup_index.sql`) that matches the rollup query's WHERE clause exactly.
|
|
||||||
- Dashboard side-rail gets rollup for free — 20 block-saves appear as one "Papa transkribierte 20 Blöcke" row with a time range, not 20 dedup'd hour buckets.
|
|
||||||
- Component reuse — `ChronikRow.svelte` renders both singleton and rollup variants via a `$derived` discriminator; `DashboardActivityFeed.svelte` consumes the same DTO shape.
|
|
||||||
|
|
||||||
**Harder:**
|
|
||||||
- The session SQL is ~15 lines longer than `DISTINCT ON`. That's the price for not splitting natural sessions at fixed boundaries — worth it on day one.
|
|
||||||
- Historical `/api/dashboard/activity` consumers now see `count` and `happenedAtUntil`. No breaking change — `count` defaults to `1`, `happenedAtUntil` is nullable — but pre-existing tests needed updating.
|
|
||||||
- Rollup is load-bearing for the UX — if the index is missing or the query regresses, the page either runs slow or returns duplicate rows. Covered by the rolledUp integration tests and the partial covering index; worth a follow-up Grafana panel on `/api/dashboard/activity` p95 latency.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# ADR-004: In-Process PDFBox Thumbnails (not ocr-service)
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The archive lists documents as text-only rows everywhere (home search, person detail, conversation timeline, Chronik). For a fundamentally visual archive — letters, scans, handwritten pages — this is a real discoverability problem. Issue #307 introduces a small JPEG thumbnail for every document.
|
|
||||||
|
|
||||||
A viable alternative to rendering in Spring Boot is delegating to the existing `ocr-service` (Python), which already has PyMuPDF/PIL available and is the project's designated place for PDF pixel work. The comparison is not obvious: either place works.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Render thumbnails in-process in Spring Boot using **Apache PDFBox 3.0.4** (already a dependency for training-data export). A dedicated `thumbnailExecutor` pool isolates the work from the shared task pool used by OCR.
|
|
||||||
|
|
||||||
- PDF first page rendered via `PDFRenderer.renderImageWithDPI(0, 100, ImageType.RGB)`, scaled to 240 px width (bilinear) and encoded as JPEG quality 85.
|
|
||||||
- Non-PDF image types (JPEG, PNG, TIFF) decoded via `javax.imageio` — TIFF requires the `twelvemonkeys-imageio-tiff` plugin on the classpath.
|
|
||||||
- Upload paths fire-and-forget via `ThumbnailAsyncRunner.dispatchAfterCommit(docId)`; a `ThumbnailBackfillService` covers anything the async task missed or that pre-dates this feature.
|
|
||||||
|
|
||||||
## Alternatives Considered
|
|
||||||
|
|
||||||
| Alternative | Why rejected |
|
|
||||||
|---|---|
|
|
||||||
| Delegate to `ocr-service` (PyMuPDF) | Adds a network hop and a failure mode to every document upload. `ocr-service` is not guaranteed healthy at upload time (model-loading start period is 60 s). PDFBox is already a backend dependency — delegating is a net complexity increase. |
|
|
||||||
| Render on the frontend with `pdfjs-dist` at display time | Would work for PDFs but not for scans / images; list pages would need to render dozens of PDFs on first paint; no server-side caching. |
|
|
||||||
| Thumbor / imaginary / a dedicated thumbnail service | Overkill for a single-operator household tool; new container to operate and secure. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**Easier:**
|
|
||||||
- Zero new infrastructure. `thumbnails/` is a prefix in the existing MinIO bucket — production migration to Hetzner Object Storage works identically.
|
|
||||||
- Backfill is a plain sequential loop; no inter-service retry semantics.
|
|
||||||
- Integration test runs against real MinIO without needing `ocr-service` to be healthy.
|
|
||||||
|
|
||||||
**Harder:**
|
|
||||||
- PDFBox is a parser attack surface. Mitigated by a 30-second watchdog timeout in `ThumbnailAsyncRunner` and by the fire-and-forget contract (failures never break upload).
|
|
||||||
- Memory ceiling: the `thumbnailExecutor` is capped at 2 threads on the CX32 (8 GB). A busy backfill alongside OCR can approach the 3 GB heap — acceptable but not comfortable. Streaming via `FileService.downloadFileStream` keeps this bounded for PDFs up to 50 MB.
|
|
||||||
|
|
||||||
### Operational caveats (intentional)
|
|
||||||
|
|
||||||
**Backfill state is in-memory and single-node.** `ThumbnailBackfillService.currentStatus` is a volatile reference updated on the thumbnail executor thread. Restarting the backend mid-run loses progress and the next `runBackfillAsync()` starts over. This mirrors `MassImportService.ImportStatus` and is acceptable because the household archive runs as a single Spring Boot process, backfill is a rare one-shot admin action, and re-running the backfill is idempotent (`findByFilePathIsNotNullAndThumbnailKeyIsNull()` naturally skips completed documents).
|
|
||||||
|
|
||||||
**`ThumbnailService` and `ThumbnailBackfillService` inject `DocumentRepository` directly.** This is a deliberate exception to the project's "services never reach into another domain's repository" rule. Treating thumbnails as a cross-cutting aspect of `Document` rather than a sub-domain avoids a circular dependency (`DocumentService` → `ThumbnailAsyncRunner` → `DocumentService` would close the loop). If thumbnail state grows beyond two columns into its own domain model, extract a proper `ThumbnailRepository` at that point — not before.
|
|
||||||
|
|
||||||
## Future Direction
|
|
||||||
|
|
||||||
- If a second image-processing job (OCR region crops, sharing previews) arrives, revisit moving all image work to `ocr-service` so the two share a single PyMuPDF instance.
|
|
||||||
- If thumbnails ever need to be generated at multiple sizes, switch the key pattern from `thumbnails/{docId}.jpg` to `thumbnails/{docId}/{width}.jpg` — the endpoint and cache-bust URL are already structured to accommodate that.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# ADR-005: thumbnailAspect + pageCount alongside the thumbnail
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Issue #305 rebalances the /briefwechsel correspondence list into PDF-thumbnail rows. Two pieces of metadata are needed at row-render time:
|
|
||||||
|
|
||||||
- **Aspect ratio** — postcards are landscape (7:5), letters are portrait (5:7). Forcing landscape scans into a portrait tile crops away the signature; forcing portrait scans into a landscape tile wastes horizontal real estate.
|
|
||||||
- **Page count** — multi-page letters should show a "N" badge on their thumbnail so the reader can tell a single-page note from a seven-page letter without clicking in.
|
|
||||||
|
|
||||||
Both values are cheap to derive at the point the thumbnail is generated (the source image is already decoded; the PDF is already loaded) and impossible to derive cheaply later (requires re-reading the S3 object).
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Persist both values as columns on `documents` and populate them inside `ThumbnailService.generate()` — the same code path that writes the JPEG to S3 and stamps `thumbnail_generated_at`.
|
|
||||||
|
|
||||||
- `thumbnail_aspect VARCHAR(16)` mapped to a Java enum `ThumbnailAspect` with two values: `PORTRAIT`, `LANDSCAPE`.
|
|
||||||
- `page_count INTEGER` — `PDDocument.getNumberOfPages()` for PDFs, `1` for image uploads.
|
|
||||||
- Aspect threshold is `source.width / source.height > 1.1` → `LANDSCAPE`; everything else (including near-square A4 scans at ratio ≈ 1.0) stays `PORTRAIT`. The 1.1 margin keeps borderline scans from flipping across the threshold on a rounding error.
|
|
||||||
- Both columns are nullable and remain `null` for historical documents until the existing `/api/admin/generate-thumbnails` backfill rerun populates them.
|
|
||||||
|
|
||||||
## Alternatives Considered
|
|
||||||
|
|
||||||
| Alternative | Why rejected |
|
|
||||||
|---|---|
|
|
||||||
| Derive aspect client-side after image load | First-paint would have all tiles in portrait, then reshuffle into landscape when the JPEG decodes — a visible jank on slow networks. The backend already has the dimensions; client-side recomputation is a waste. |
|
|
||||||
| Store full `width` / `height` columns | Not needed anywhere — consumers want the categorical answer. If a future feature needs exact dimensions, they can be added later without migrating existing rows. |
|
|
||||||
| A separate `thumbnail_metadata` table | Two scalar nullable columns aren't worth a join. See ADR-004 — thumbnails are modeled as a cross-cutting aspect of `Document`, not a sub-domain. |
|
|
||||||
| Derive page count from the existing PDF at render time on the frontend | Duplicates work already done on the backend and requires a separate byte-range fetch of the PDF header. Frontend already gets `pageCount` "for free" via the Document response. |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**Easier:**
|
|
||||||
- `ConversationThumbnail.svelte` picks the tile dimensions from `thumbnailAspect` directly — no async measurement, no layout shift.
|
|
||||||
- `ThumbnailRow` reads `pageCount` synchronously for the badge. Multi-page letters are distinguishable at first paint.
|
|
||||||
- Backfill runs the same migration path for every old document — re-executing generates the aspect + pageCount columns along with the JPEG, so operators don't have a second admin button to click.
|
|
||||||
|
|
||||||
**Harder:**
|
|
||||||
- Both columns are `null` for every document until the backfill runs on a given instance. Frontend components guard with `?? 'PORTRAIT'` / `?? 1` so the UI stays sensible during the rollout window. The backfill is idempotent and cheap (reuses existing S3 object), so re-running it is the simplest recovery path.
|
|
||||||
- The aspect threshold is a single constant in Java. A future need to tune per-type (e.g. postcards vs photos) means a code change, not a configuration change — acceptable for a single-operator archive.
|
|
||||||
|
|
||||||
### Ordering inside `ThumbnailService.generate()`
|
|
||||||
|
|
||||||
Aspect computation happens AFTER the JPEG upload succeeds but BEFORE the entity save — if the save throws, the columns rewind with it. Page count is captured while the `PDDocument` is still open; the `SourcePreview` record carries both the rendered first-page image and the page count back to the top of the pipeline so the PDF isn't reopened later.
|
|
||||||
|
|
||||||
## Future Direction
|
|
||||||
|
|
||||||
- If a postcard-specific "photo" chip is ever reintroduced, reuse `thumbnailAspect === 'LANDSCAPE' && pageCount === 1` rather than adding a new `kind` column.
|
|
||||||
- If multi-size thumbnails are introduced (per ADR-004's future note), the aspect + pageCount are per-document and do not need to be duplicated per size.
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Spec 1 — Rich Rows · Briefwechsel</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="_shared.css">
|
|
||||||
<style>
|
|
||||||
/* Spec 1 specific */
|
|
||||||
.rlist{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden}
|
|
||||||
.row{display:grid;grid-template-columns:20px minmax(0,1fr) auto;column-gap:12px;align-items:stretch;padding:14px 18px;border-bottom:1px solid var(--line-2);border-left:3px solid transparent;cursor:pointer;transition:background .1s}
|
|
||||||
.row:hover{background:var(--muted)}
|
|
||||||
.row:last-child{border-bottom:0}
|
|
||||||
.row.out{border-left-color:var(--primary)}
|
|
||||||
.row.in{border-left-color:var(--accent)}
|
|
||||||
.row-arrow{align-self:center;font-size:14px;opacity:.55;display:flex;justify-content:center}
|
|
||||||
.row-body{min-width:0;display:flex;flex-direction:column;gap:4px}
|
|
||||||
.row-title{font-family:'Merriweather',serif;font-size:15px;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
||||||
.row-summary{font-size:12.5px;color:#555;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:90%}
|
|
||||||
.row-meta{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:11.5px;color:var(--ink-3);align-items:center}
|
|
||||||
.row-meta .sep{color:#bbb}
|
|
||||||
.row-meta .ico{width:12px;height:12px;opacity:.55;display:inline-flex;align-items:center;justify-content:center}
|
|
||||||
.row-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
|
|
||||||
.row-right{display:flex;flex-direction:column;align-items:flex-end;justify-content:center;gap:4px;min-width:130px;padding-left:16px;border-left:1px dashed var(--line)}
|
|
||||||
.row-archive{font-size:10px;font-weight:800;letter-spacing:.8px;color:#888;text-transform:uppercase;background:#F4F1EA;padding:3px 8px;border-radius:2px}
|
|
||||||
.row-archive small{display:block;font-weight:600;color:#aaa;margin-top:1px;text-transform:none;letter-spacing:0;font-size:9.5px}
|
|
||||||
@media (max-width: 900px){ .row-right{display:none} }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="spec-meta">
|
|
||||||
<div class="spec-meta-inner">
|
|
||||||
<div>
|
|
||||||
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
|
|
||||||
<p>Five approaches to turning the empty right-hand space into information that helps users scan and decide.</p>
|
|
||||||
</div>
|
|
||||||
<div class="spec-meta-right">
|
|
||||||
<div><strong>Concept</strong>Rich Rows</div>
|
|
||||||
<div><strong>Spec</strong>1 / 5</div>
|
|
||||||
<div><strong>Effort</strong>Small — no new backend data</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav class="spec-nav">
|
|
||||||
<div class="spec-nav-inner">
|
|
||||||
<span class="lbl">Specs</span>
|
|
||||||
<a href="index.html">Overview</a>
|
|
||||||
<a class="on" href="01-rich-rows.html">1 · Rich Rows</a>
|
|
||||||
<a href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
|
|
||||||
<a href="03-master-detail.html">3 · Master-Detail Split</a>
|
|
||||||
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
|
|
||||||
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="page-wrap">
|
|
||||||
|
|
||||||
<!-- Real Familienarchiv chrome -->
|
|
||||||
<div class="hdr">
|
|
||||||
<div class="hdr-logo">FAMILIENARCHIV</div>
|
|
||||||
<div class="hdr-nav">
|
|
||||||
<a>Documents</a><a>Persons</a><a class="on">Letters</a><a>Admin</a>
|
|
||||||
</div>
|
|
||||||
<div class="hdr-right">
|
|
||||||
<div class="hdr-upload">⬆ UPLOAD</div>
|
|
||||||
<span>DE · EN · ES</span>
|
|
||||||
<div class="hdr-avatar">MR</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
|
|
||||||
<div class="concept-intro">
|
|
||||||
<h2>Concept 1 · Rich Rows — pack more metadata into each row</h2>
|
|
||||||
No visuals, no structural change. Each row grows from a single line to a layered block: title (serif), summary (italic), meta row with icons, tag chips, and a right-hand column with archive box, script type and status.
|
|
||||||
<div><span class="gain">✚ Zero backend changes</span><span class="gain">✚ Still one scrollable list</span><span class="cost">− Heavier rows; 10-row view becomes ~6–7 rows</span><span class="cost">− Empty-looking when a doc has no summary/tags</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter card (same as production) -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="filter-row">
|
|
||||||
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
|
|
||||||
<div><div class="fl">Korrespondent — optional</div><div class="fi empty">Alle Korrespondenten</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="filter-actions">
|
|
||||||
<div class="btn">Newest ↓</div>
|
|
||||||
<div class="btn">▾ Filter</div>
|
|
||||||
<div class="count"><b>851</b> Briefe</div>
|
|
||||||
</div>
|
|
||||||
<div class="hintbar">📋 Alle Briefe von <b>Walter de Gruyter</b> — wähle einen Korrespondenten oben um einzugrenzen</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rlist">
|
|
||||||
<div class="year-divider"><span class="y">1940</span><span class="n">1 Brief</span></div>
|
|
||||||
|
|
||||||
<div class="row in">
|
|
||||||
<div class="row-arrow">←</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">Demo leserlicher Brief</div>
|
|
||||||
<div class="row-summary">„letzte Lebenstage von W. Dörpfeld in Griechenland"</div>
|
|
||||||
<div class="row-meta"><span>31. Mai 1940</span><span class="sep">·</span><span>📍 Belgard</span><span class="sep">·</span><span>von <b>Gertrud von Rofden</b></span></div>
|
|
||||||
<div class="row-tags"><span class="tag">Dörpfeld</span><span class="tag">Griechenland</span><span class="tag muted">privat</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-archive">Kasten VII · Mappe 5</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="year-divider"><span class="y">1923</span><span class="n">5 Briefe</span></div>
|
|
||||||
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-arrow">→</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0397 – 2. September 1923 – B.Lichterfelde</div>
|
|
||||||
<div class="row-summary">„von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte"</div>
|
|
||||||
<div class="row-meta"><span>2. September 1923</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span>an <b>Herbert Cram</b></span></div>
|
|
||||||
<div class="row-tags"><span class="tag">Verlag</span><span class="tag">Familie</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-archive">Kasten VI · Mappe 7</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-arrow">→</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0396 – 2. September 1923 – B.Lichterfelde</div>
|
|
||||||
<div class="row-summary">—</div>
|
|
||||||
<div class="row-meta"><span>2. September 1923</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span>an <b>Herbert Cram</b></span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-archive">Kasten VI · Mappe 7</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-arrow">→</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0524 – 31. Juli 1923 – Berlin</div>
|
|
||||||
<div class="row-summary">„Glückwunsch zum 60. Geburtstag, Bericht über den Verlag"</div>
|
|
||||||
<div class="row-meta"><span>31. Juli 1923</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span>an <b>Walter Dieckmann</b></span></div>
|
|
||||||
<div class="row-tags"><span class="tag">Geburtstag</span><span class="tag">Verlag</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-archive">Kasten VI · Mappe 7</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="year-divider"><span class="y">1922</span><span class="n">37 Briefe</span></div>
|
|
||||||
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-arrow">→</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0521 – 24. Dezember 1922 – Berlin</div>
|
|
||||||
<div class="row-summary">„Weihnachtsbrief, Erinnerungen an das Jahr und Bitte um ein Bild der Kinder"</div>
|
|
||||||
<div class="row-meta"><span>24. Dezember 1922</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span>an <b>Walter Dieckmann</b></span></div>
|
|
||||||
<div class="row-tags"><span class="tag">Weihnachten</span><span class="tag">Familie</span><span class="tag muted">persönlich</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-archive">Kasten V · Mappe 3</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-arrow">→</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0392 – 23. November 1921 – Bad Kissingen</div>
|
|
||||||
<div class="row-summary">„Kurbericht, Gesundheitsupdate, Grüße an die Familie Cram"</div>
|
|
||||||
<div class="row-meta"><span>23. November 1921</span><span class="sep">·</span><span>📍 Bad Kissingen</span><span class="sep">·</span><span>an <b>Herbert Cram</b></span></div>
|
|
||||||
<div class="row-tags"><span class="tag">Kuraufenthalt</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-archive">Kasten V · Mappe 1</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-arrow">→</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0501 – 13. Dezember 1920 – Berlin</div>
|
|
||||||
<div class="row-summary">—</div>
|
|
||||||
<div class="row-meta"><span>13. Dezember 1920</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span>an <b>Walter Dieckmann</b></span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-archive">Kasten IV · Mappe 8</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Spec 2 — Thumbnail Rows · Briefwechsel</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Merriweather:wght@400;700&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="_shared.css">
|
|
||||||
<style>
|
|
||||||
/* Spec 2 v2 — bigger thumbnails, postcard support, bilateral distribution bar */
|
|
||||||
.rlist{background:#fff;border:1px solid var(--line);border-radius:2px;overflow:hidden}
|
|
||||||
|
|
||||||
.row{display:grid;grid-template-columns:104px 1fr auto;column-gap:20px;align-items:center;padding:14px 20px;border-bottom:1px solid var(--line-2);border-left:3px solid transparent;cursor:pointer;transition:background .12s,box-shadow .12s}
|
|
||||||
.row:hover{background:var(--muted)}
|
|
||||||
.row:hover .row-thumb .thumb{transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,.1),inset 0 0 0 1px #fff}
|
|
||||||
.row:last-child{border-bottom:0}
|
|
||||||
.row.out{border-left-color:var(--primary)}
|
|
||||||
.row.in{border-left-color:var(--accent)}
|
|
||||||
|
|
||||||
/* Thumbnail wrapper — fixed 104×104 cell, thumb centered */
|
|
||||||
.row-thumb{width:104px;height:120px;display:flex;align-items:center;justify-content:center;position:relative}
|
|
||||||
.thumb{transition:transform .12s,box-shadow .12s;box-shadow:0 1px 3px rgba(0,0,0,.08),inset 0 0 0 1px #fff}
|
|
||||||
.thumb.portrait{width:82px;height:106px}
|
|
||||||
.thumb.landscape{width:104px;height:72px}
|
|
||||||
.thumb.postcard{width:104px;height:66px}
|
|
||||||
.thumb-badge{position:absolute;top:2px;right:0;background:var(--brand-navy);color:#fff;font-size:9px;font-weight:800;padding:2px 6px;border-radius:10px;box-shadow:0 0 0 2px #fff}
|
|
||||||
|
|
||||||
/* Subtle paper variations for natural feel */
|
|
||||||
.thumb.paper-1{background:linear-gradient(180deg,#fdfcf7 0%,#f4efdf 100%)}
|
|
||||||
.thumb.paper-2{background:linear-gradient(180deg,#fefdf8 0%,#eee8d3 100%)}
|
|
||||||
.thumb.paper-3{background:linear-gradient(180deg,#fbf8ed 0%,#efe7cb 100%)}
|
|
||||||
.thumb.paper-4{background:linear-gradient(180deg,#fdfcf5 0%,#f0e9d5 100%)}
|
|
||||||
|
|
||||||
/* Kurrent-style handwriting — denser, angled */
|
|
||||||
.thumb.kurrent .thumb-lines{padding:14% 9%;gap:3.5px}
|
|
||||||
.thumb.kurrent .thumb-lines i{height:1.3px;background:rgba(24,40,70,.45);transform:rotate(-.5deg)}
|
|
||||||
.thumb.kurrent .thumb-lines i:nth-child(3n){width:65%}
|
|
||||||
.thumb.kurrent .thumb-lines i:nth-child(4n){width:92%}
|
|
||||||
.thumb.kurrent .thumb-lines i:nth-child(5n){width:48%;transform:rotate(.4deg)}
|
|
||||||
|
|
||||||
/* Typewriter — regular, crisp */
|
|
||||||
.thumb.typed .thumb-lines{padding:16% 12%;gap:2.5px}
|
|
||||||
.thumb.typed .thumb-lines i{height:1px;background:rgba(40,40,40,.45)}
|
|
||||||
.thumb.typed .thumb-lines i:nth-child(odd){width:93%}
|
|
||||||
.thumb.typed .thumb-lines i:nth-child(even){width:88%}
|
|
||||||
.thumb.typed .thumb-lines i:nth-child(7n){width:45%}
|
|
||||||
|
|
||||||
/* Postcard — stamp corner + postmark + short address lines */
|
|
||||||
.thumb.postcard .thumb-lines{padding:10% 10% 14% 10%;gap:4px}
|
|
||||||
.thumb.postcard .thumb-lines i{height:1.1px;background:rgba(24,40,70,.45)}
|
|
||||||
.thumb.postcard .thumb-lines i:nth-child(1){width:60%}
|
|
||||||
.thumb.postcard .thumb-lines i:nth-child(2){width:45%}
|
|
||||||
.thumb.postcard .thumb-lines i:nth-child(3){width:70%}
|
|
||||||
.thumb.postcard .thumb-lines i:nth-child(4){width:40%}
|
|
||||||
.thumb.postcard::after{content:'';position:absolute;top:6px;right:6px;width:16px;height:18px;background:linear-gradient(135deg,#b6c9d3,#8ba9b6);border:1px dashed rgba(0,0,0,.15);box-shadow:0 0 0 1px #fff}
|
|
||||||
.thumb.postcard::before{content:'';position:absolute;top:10px;right:26px;width:14px;height:14px;border:1.5px solid rgba(150,30,30,.4);border-radius:50%;background:radial-gradient(circle,rgba(150,30,30,.1) 40%,transparent 60%)}
|
|
||||||
|
|
||||||
/* Letter heading (typed with date/address at top) */
|
|
||||||
.thumb.typed::before{content:'';position:absolute;top:10%;left:12%;right:12%;height:2px;background:transparent;border-bottom:1.5px solid rgba(40,40,40,.35)}
|
|
||||||
|
|
||||||
.row-body{min-width:0;display:flex;flex-direction:column;gap:4px}
|
|
||||||
.row-title{font-family:'Merriweather',serif;font-size:16px;font-weight:700;color:var(--ink);line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
||||||
.row-summary{font-family:'Merriweather',serif;font-size:14px;color:#444;line-height:1.55;font-style:italic;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
||||||
.row-summary::before{content:'„';color:var(--brand-mint);font-size:22px;font-weight:700;line-height:0;position:relative;top:6px;margin-right:2px}
|
|
||||||
.row-summary::after{content:'”';color:var(--brand-mint);font-size:22px;font-weight:700;line-height:0;position:relative;top:6px;margin-left:2px}
|
|
||||||
.row-meta{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--ink-3);align-items:center;margin-top:2px}
|
|
||||||
.row-meta .sep{color:#ccc}
|
|
||||||
.row-meta .dir-ch{color:var(--primary);font-weight:800;font-size:13px}
|
|
||||||
.row-meta .dir-ch.in{color:var(--accent)}
|
|
||||||
.row-meta .kind-chip{display:inline-flex;align-items:center;gap:3px;background:#F4F1EA;color:#666;font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;letter-spacing:.3px;text-transform:uppercase}
|
|
||||||
.row-tags{display:flex;gap:4px;flex-wrap:wrap}
|
|
||||||
|
|
||||||
.row-right{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
|
|
||||||
.row-date{font-family:'Merriweather',serif;font-size:14px;color:#444;white-space:nowrap;font-weight:700}
|
|
||||||
.row-date-rel{font-size:10.5px;color:#aaa;font-weight:600;letter-spacing:.3px}
|
|
||||||
|
|
||||||
/* Bilateral distribution bar — lifted from production ConversationTimeline */
|
|
||||||
.distbar{display:flex;flex-direction:column;gap:6px;background:var(--muted);border:1px solid var(--line);border-bottom:0;padding:12px 20px}
|
|
||||||
.distbar-labels{display:flex;justify-content:space-between;font-size:13px;font-weight:700}
|
|
||||||
.distbar-labels .out{color:var(--primary);display:inline-flex;align-items:center;gap:6px}
|
|
||||||
.distbar-labels .in{color:var(--accent);display:inline-flex;align-items:center;gap:6px}
|
|
||||||
.distbar-labels .cnt{font-variant-numeric:tabular-nums}
|
|
||||||
.distbar-bar{height:6px;display:flex;border-radius:3px;overflow:hidden;background:var(--line)}
|
|
||||||
.distbar-bar .out{background:var(--primary)}
|
|
||||||
.distbar-bar .in{background:var(--accent)}
|
|
||||||
.distbar + .rlist{border-radius:0 0 2px 2px}
|
|
||||||
|
|
||||||
/* Section headings within the spec */
|
|
||||||
.example-h{font-family:'Merriweather',serif;font-size:18px;color:var(--brand-navy);margin:36px 0 10px;padding-top:24px;border-top:1px dashed var(--line);font-weight:700;display:flex;align-items:baseline;gap:10px}
|
|
||||||
.example-h .lbl{font-family:'Montserrat',sans-serif;font-size:10px;font-weight:800;color:#888;letter-spacing:1px;text-transform:uppercase}
|
|
||||||
.example-h:first-of-type{border-top:0;padding-top:0;margin-top:20px}
|
|
||||||
.example-sub{font-size:12px;color:#777;margin-bottom:14px;line-height:1.55}
|
|
||||||
|
|
||||||
/* Swap-buttons and filter chrome for bilateral filter card */
|
|
||||||
.swap-inline{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border:1px solid #C8C4BE;border-radius:50%;background:#F0EDE8;font-size:13px;color:var(--brand-navy);margin:0 -12px;position:relative;z-index:1}
|
|
||||||
|
|
||||||
@media (max-width: 760px){
|
|
||||||
.row{grid-template-columns:82px 1fr;column-gap:14px}
|
|
||||||
.row-right{grid-column:2;align-items:flex-start;margin-top:4px}
|
|
||||||
.thumb.portrait{width:72px;height:94px}
|
|
||||||
.thumb.landscape, .thumb.postcard{width:82px;height:58px}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="spec-meta">
|
|
||||||
<div class="spec-meta-inner">
|
|
||||||
<div>
|
|
||||||
<h1>Briefwechsel — <span>Fill the Empty Rows</span></h1>
|
|
||||||
<p>Five approaches to turning the empty right-hand space into information that helps users scan and decide.</p>
|
|
||||||
</div>
|
|
||||||
<div class="spec-meta-right">
|
|
||||||
<div><strong>Concept</strong>Thumbnail Rows <span style="color:var(--brand-mint);margin-left:6px">v2</span></div>
|
|
||||||
<div><strong>Spec</strong>2 / 5</div>
|
|
||||||
<div><strong>Effort</strong>Medium — needs PDF thumbnail service</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav class="spec-nav">
|
|
||||||
<div class="spec-nav-inner">
|
|
||||||
<span class="lbl">Specs</span>
|
|
||||||
<a href="index.html">Overview</a>
|
|
||||||
<a href="01-rich-rows.html">1 · Rich Rows</a>
|
|
||||||
<a class="on" href="02-thumbnail-rows.html">2 · Thumbnail Rows</a>
|
|
||||||
<a href="03-master-detail.html">3 · Master-Detail Split</a>
|
|
||||||
<a href="04-gallery-cards.html">4 · Gallery Cards</a>
|
|
||||||
<a href="05-person-dashboard.html">5 · Person Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="page-wrap">
|
|
||||||
|
|
||||||
<div class="hdr">
|
|
||||||
<div class="hdr-logo">FAMILIENARCHIV</div>
|
|
||||||
<div class="hdr-nav"><a>Documents</a><a>Persons</a><a class="on">Letters</a><a>Admin</a></div>
|
|
||||||
<div class="hdr-right"><div class="hdr-upload">⬆ UPLOAD</div><span>DE · EN · ES</span><div class="hdr-avatar">MR</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
|
|
||||||
<div class="concept-intro">
|
|
||||||
<h2>Concept 2 · Thumbnail Rows — discovery through visual + summary</h2>
|
|
||||||
/briefwechsel is for fun discovery, not dense scanning. The row gets a bigger first-page thumbnail (portrait for letters, landscape for postcards); the <b>summary</b> reads like a quote next to it; the right column stays calm — just the date. Rows without a summary remain clean and uncrowded.
|
|
||||||
<div><span class="gain">✚ Visual recognition — letters and postcards look like what they are</span><span class="gain">✚ Summary reads as a quote, invites opening the letter</span><span class="gain">✚ Distribution bar gives the bilateral pair its own identity</span><span class="cost">− Depends on the PDF-thumbnail service (open issue)</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ───────── Example 1 · single person ───────── -->
|
|
||||||
<div class="example-h">Beispiel 1 <span class="lbl">alle Briefe von Walter de Gruyter · 851</span></div>
|
|
||||||
<div class="example-sub">Single-sender case: sender is filled, correspondent is open. Direction arrows tell sent vs received.</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="filter-row">
|
|
||||||
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
|
|
||||||
<div><div class="fl">Korrespondent — optional</div><div class="fi empty">Alle Korrespondenten</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="filter-actions">
|
|
||||||
<div class="btn">Newest ↓</div><div class="btn">▾ Filter</div>
|
|
||||||
<div class="count"><b>851</b> Briefe</div>
|
|
||||||
</div>
|
|
||||||
<div class="hintbar">📋 Alle Briefe von <b>Walter de Gruyter</b> — wähle einen Korrespondenten oben um einzugrenzen</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rlist">
|
|
||||||
<div class="year-divider"><span class="y">1940</span><span class="n">1 Brief</span></div>
|
|
||||||
|
|
||||||
<div class="row in">
|
|
||||||
<div class="row-thumb">
|
|
||||||
<div class="thumb portrait typed paper-1">
|
|
||||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">Demo leserlicher Brief</div>
|
|
||||||
<div class="row-summary">letzte Lebenstage von W. Dörpfeld in Griechenland — ausführlicher Bericht aus Belgard mit persönlichen Anmerkungen</div>
|
|
||||||
<div class="row-meta"><span class="dir-ch in">← eingehend</span><span>Gertrud von Rofden</span><span class="sep">·</span><span>📍 Belgard</span><span class="sep">·</span><span class="row-tags"><span class="tag">Dörpfeld</span><span class="tag">Griechenland</span></span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-date">31. Mai 1940</div>
|
|
||||||
<div class="row-date-rel">vor 85 Jahren</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="year-divider"><span class="y">1923</span><span class="n">5 Briefe</span></div>
|
|
||||||
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-thumb">
|
|
||||||
<div class="thumb portrait kurrent paper-2">
|
|
||||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0397 – 2. September 1923 – B.Lichterfelde</div>
|
|
||||||
<div class="row-summary">von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte — Notiz auf der Rückseite mit Korrekturen</div>
|
|
||||||
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Herbert Cram</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span class="row-tags"><span class="tag">Verlag</span><span class="tag">Familie</span></span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-date">2. September 1923</div>
|
|
||||||
<div class="row-date-rel">vor 102 Jahren</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Postcard example -->
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-thumb">
|
|
||||||
<div class="thumb postcard kurrent paper-4">
|
|
||||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">Ansichtskarte – 2. September 1923 – B.Lichterfelde</div>
|
|
||||||
<div class="row-summary">kurze Grüße aus B.Lichterfelde, Hinweis auf den kommenden Besuch</div>
|
|
||||||
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Herbert Cram</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span class="kind-chip">✉ Postkarte</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-date">2. September 1923</div>
|
|
||||||
<div class="row-date-rel">vor 102 Jahren</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Multi-page letter -->
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-thumb">
|
|
||||||
<div class="thumb portrait kurrent paper-3">
|
|
||||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
||||||
</div>
|
|
||||||
<span class="thumb-badge">4 S.</span>
|
|
||||||
</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0524 – 31. Juli 1923 – Berlin</div>
|
|
||||||
<div class="row-summary">Glückwunsch zum 60. Geburtstag, Bericht über den Verlag und den anstehenden Umzug nach B.Lichterfelde im kommenden Herbst</div>
|
|
||||||
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Walter Dieckmann</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span class="row-tags"><span class="tag">Geburtstag</span><span class="tag">Verlag</span></span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-date">31. Juli 1923</div>
|
|
||||||
<div class="row-date-rel">vor 102 Jahren</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Without summary — still clean -->
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-thumb">
|
|
||||||
<div class="thumb portrait kurrent paper-1">
|
|
||||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0396 – 2. September 1923 – B.Lichterfelde</div>
|
|
||||||
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Herbert Cram</span><span class="sep">·</span><span>📍 B.Lichterfelde</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-date">2. September 1923</div>
|
|
||||||
<div class="row-date-rel">vor 102 Jahren</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="year-divider"><span class="y">1922</span><span class="n">37 Briefe</span></div>
|
|
||||||
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-thumb">
|
|
||||||
<div class="thumb portrait kurrent paper-2">
|
|
||||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0521 – 24. Dezember 1922 – Berlin</div>
|
|
||||||
<div class="row-summary">Weihnachtsbrief, Erinnerungen an das Jahr und Bitte um ein Bild der Kinder zum Christfest</div>
|
|
||||||
<div class="row-meta"><span class="dir-ch">→ ausgehend</span><span>an Walter Dieckmann</span><span class="sep">·</span><span>📍 Berlin</span><span class="sep">·</span><span class="row-tags"><span class="tag">Weihnachten</span><span class="tag">Familie</span></span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-date">24. Dezember 1922</div>
|
|
||||||
<div class="row-date-rel">vor 103 Jahren</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ───────── Example 2 · bilateral ───────── -->
|
|
||||||
<div class="example-h">Beispiel 2 <span class="lbl">Briefwechsel Walter ↔ Herbert · 143</span></div>
|
|
||||||
<div class="example-sub">Bilateral case: both filters are set. The distribution bar above the list shows how the correspondence is split — instantly visible who wrote more.</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="filter-row">
|
|
||||||
<div><div class="fl">Person</div><div class="fi">Walter de Gruyter</div></div>
|
|
||||||
<div><div class="fl">Korrespondent</div><div class="fi">Herbert Cram</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="filter-actions">
|
|
||||||
<div class="btn">⇄ Tauschen</div>
|
|
||||||
<div class="btn">Newest ↓</div><div class="btn">▾ Filter</div>
|
|
||||||
<div class="count"><b>143</b> Briefe im Zeitraum</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="distbar" role="img" aria-label="Briefverteilung: 87 von Walter de Gruyter, 56 von Herbert Cram">
|
|
||||||
<div class="distbar-labels">
|
|
||||||
<span class="out"><span class="cnt">87</span> von Walter de Gruyter →</span>
|
|
||||||
<span class="in">← <span class="cnt">56</span> von Herbert Cram</span>
|
|
||||||
</div>
|
|
||||||
<div class="distbar-bar">
|
|
||||||
<span class="out" style="width:60.8%"></span>
|
|
||||||
<span class="in" style="width:39.2%"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="rlist" style="border-radius:0 0 2px 2px">
|
|
||||||
<div class="year-divider"><span class="y">1923</span><span class="n">12 Briefe</span></div>
|
|
||||||
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-thumb">
|
|
||||||
<div class="thumb portrait kurrent paper-2">
|
|
||||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0397 – 2. September 1923 – B.Lichterfelde</div>
|
|
||||||
<div class="row-summary">von Elsbeth geschriebener Kommentar, den Herbert zum Brief erzählte</div>
|
|
||||||
<div class="row-meta"><span class="dir-ch">→</span><span>Walter an Herbert</span><span class="sep">·</span><span>📍 B.Lichterfelde</span><span class="sep">·</span><span class="row-tags"><span class="tag">Verlag</span></span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-date">2. September 1923</div>
|
|
||||||
<div class="row-date-rel">vor 102 Jahren</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row in">
|
|
||||||
<div class="row-thumb">
|
|
||||||
<div class="thumb portrait kurrent paper-3">
|
|
||||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">H-0213 – 29. August 1923 – Leipzig</div>
|
|
||||||
<div class="row-summary">Antwort auf Walters Anfrage zur Herbstauslieferung, Bitte um Rückmeldung bis Monatsende</div>
|
|
||||||
<div class="row-meta"><span class="dir-ch in">←</span><span>Herbert an Walter</span><span class="sep">·</span><span>📍 Leipzig</span><span class="sep">·</span><span class="row-tags"><span class="tag">Verlag</span></span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-date">29. August 1923</div>
|
|
||||||
<div class="row-date-rel">vor 102 Jahren</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row in">
|
|
||||||
<div class="row-thumb">
|
|
||||||
<div class="thumb postcard kurrent paper-4">
|
|
||||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">Ansichtskarte – 20. August 1923 – Thüringer Wald</div>
|
|
||||||
<div class="row-summary">Urlaubsgruß, kurze Notiz über Wetter und geplante Rückkehr</div>
|
|
||||||
<div class="row-meta"><span class="dir-ch in">←</span><span>Herbert an Walter</span><span class="sep">·</span><span>📍 Thüringer Wald</span><span class="sep">·</span><span class="kind-chip">✉ Postkarte</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-date">20. August 1923</div>
|
|
||||||
<div class="row-date-rel">vor 102 Jahren</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row out">
|
|
||||||
<div class="row-thumb">
|
|
||||||
<div class="thumb portrait kurrent paper-1">
|
|
||||||
<div class="thumb-lines"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
|
||||||
</div>
|
|
||||||
<span class="thumb-badge">3 S.</span>
|
|
||||||
</div>
|
|
||||||
<div class="row-body">
|
|
||||||
<div class="row-title">W-0392 – 23. November 1921 – Bad Kissingen</div>
|
|
||||||
<div class="row-summary">Kurbericht aus Bad Kissingen, Gesundheitsupdate nach der ersten Woche, Grüße an die Familie Cram</div>
|
|
||||||
<div class="row-meta"><span class="dir-ch">→</span><span>Walter an Herbert</span><span class="sep">·</span><span>📍 Bad Kissingen</span><span class="sep">·</span><span class="row-tags"><span class="tag">Kuraufenthalt</span></span></div>
|
|
||||||
</div>
|
|
||||||
<div class="row-right">
|
|
||||||
<div class="row-date">23. November 1921</div>
|
|
||||||
<div class="row-date-rel">vor 104 Jahren</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notes footer -->
|
|
||||||
<div style="margin-top:32px;padding:16px 20px;background:#fff;border-left:4px solid var(--brand-navy);font-size:13px;color:#333;line-height:1.7">
|
|
||||||
<b style="color:var(--brand-navy)">Details:</b>
|
|
||||||
<ul style="margin:8px 0 0 20px;padding:0">
|
|
||||||
<li><b>Thumbnail</b> — 82×106 for portrait, 104×72 for landscape/postcards. Postcards also get a stamp + postmark corner. Kurrent handwriting rendered with slight line skew; typewriter rendered with clean parallel lines. Multi-page letters get a "<code>4 S.</code>" badge.</li>
|
|
||||||
<li><b>Summary</b> — shown in serif italic with colored quote marks. Reads like a quote from the letter. If empty, the row simply omits the line — no apologetic placeholder.</li>
|
|
||||||
<li><b>Right column</b> — date only, in serif. We dropped archive box (only meaningful for one family archive) and any lookup metadata. The right column stays calm on purpose.</li>
|
|
||||||
<li><b>Distribution bar</b> — appears only in bilateral mode (both sender and receiver set). Pattern lifted from the existing <code>ConversationTimeline</code> so it's familiar.</li>
|
|
||||||
<li><b>Mobile</b> — thumbnail shrinks (72×94 portrait / 82×58 landscape) and the right column wraps under the body.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user