Compare commits

..

1 Commits

Author SHA1 Message Date
Marcel
8b422c8f06 fix(e2e): wait for hydration on document detail page in history tests
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m6s
CI / Backend Unit Tests (pull_request) Successful in 2m3s
CI / E2E Tests (pull_request) Failing after 21m29s
All three history tests navigated to the doc page but didn't wait for
SvelteKit hydration, so the toggle onclick wasn't registered yet. Also
wait for versions to load (API call) before asserting on version items
or the compare button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 13:37:39 +01:00
329 changed files with 4378 additions and 33842 deletions

View File

@@ -28,10 +28,6 @@ jobs:
run: npm ci
working-directory: frontend
- name: Compile Paraglide i18n
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
working-directory: frontend
- name: Lint
run: npm run lint
working-directory: frontend

View File

@@ -34,10 +34,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
@@ -69,16 +65,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator-test</artifactId>
@@ -162,7 +148,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<spring.profiles.active>dev,e2e</spring.profiles.active>
<spring.profiles.active>dev</spring.profiles.active>
</properties>
</profile>
<profile>
@@ -175,50 +161,6 @@
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<configuration>
<excludes>
<exclude>**/dto/**</exclude>
<exclude>**/config/**</exclude>
<exclude>**/exception/ErrorCode*</exclude>
<exclude>**/model/**</exclude>
</excludes>
</configuration>
<executions>
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals><goal>report</goal></goals>
</execution>
<!-- Gate: baseline 89.4% overall / service 90.2% / controller 80.0% -->
<execution>
<id>check</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.88</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>

View File

@@ -49,7 +49,7 @@ public class DataInitializer {
// 1. Admin Gruppe erstellen
UserGroup adminGroup = UserGroup.builder()
.name("Administrators")
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
.build();
groupRepository.save(adminGroup);
@@ -84,32 +84,8 @@ public class DataInitializer {
TagRepository tagRepo,
PasswordEncoder passwordEncoder) {
return args -> {
// Always reset the admin password to the configured value so a failed password-reset
// test from a previous run can never leave the account locked out.
userRepository.findByUsername(adminUsername).ifPresent(admin -> {
admin.setPassword(passwordEncoder.encode(adminPassword));
userRepository.save(admin);
log.info("E2E seed: Admin-Passwort auf konfigurierten Wert zurückgesetzt.");
});
// Always ensure the read-only test user exists, even when seed data was already loaded.
if (userRepository.findByUsername("reader").isEmpty()) {
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
groupRepository.save(UserGroup.builder()
.name("Leser")
.permissions(Set.of("READ_ALL"))
.build()));
userRepository.save(AppUser.builder()
.username("reader")
.password(passwordEncoder.encode("reader123"))
.groups(Set.of(leserGroup))
.build());
log.info("E2E seed: 'reader'-Testbenutzer erstellt.");
}
if (personRepo.count() > 0) {
log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed.");
log.info("E2E seed: Daten bereits vorhanden, überspringe.");
return;
}
@@ -190,6 +166,19 @@ public class DataInitializer {
.receivers(Set.of(otto))
.build());
// ── Read-only user (for permissions E2E tests) ───────────────────
// Username: reader / Password: reader123
// Has only READ_ALL — used to assert write controls are absent.
UserGroup leserGroup = groupRepository.save(UserGroup.builder()
.name("Leser")
.permissions(Set.of("READ_ALL"))
.build());
userRepository.save(AppUser.builder()
.username("reader")
.password(passwordEncoder.encode("reader123"))
.groups(Set.of(leserGroup))
.build());
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente, {} Benutzer erstellt.",
personRepo.count(), tagRepo.count(), docRepo.count(), userRepository.count());
};

View File

@@ -41,10 +41,4 @@ public class AdminController {
documentService.getDocumentsWithoutVersions());
return ResponseEntity.ok(new BackfillResult(count));
}
@PostMapping("/backfill-file-hashes")
public ResponseEntity<BackfillResult> backfillFileHashes() {
int count = documentService.backfillFileHashes();
return ResponseEntity.ok(new BackfillResult(count));
}
}

View File

@@ -1,71 +0,0 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/documents/{documentId}/annotations")
@RequiredArgsConstructor
@Slf4j
public class AnnotationController {
private final AnnotationService annotationService;
private final DocumentService documentService;
private final UserService userService;
@GetMapping
public List<DocumentAnnotation> listAnnotations(@PathVariable UUID documentId) {
return annotationService.listAnnotations(documentId);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentAnnotation createAnnotation(
@PathVariable UUID documentId,
@RequestBody CreateAnnotationDTO dto,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
Document doc = documentService.getDocumentById(documentId);
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
}
@DeleteMapping("/{annotationId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public void deleteAnnotation(
@PathVariable UUID documentId,
@PathVariable UUID annotationId,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
annotationService.deleteAnnotation(documentId, annotationId, userId);
}
// ─── private helpers ──────────────────────────────────────────────────────
private UUID resolveUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
AppUser user = userService.findByUsername(authentication.getName());
return user != null ? user.getId() : null;
} catch (Exception e) {
log.warn("Could not resolve user for annotation: {}", e.getMessage());
return null;
}
}
}

View File

@@ -10,8 +10,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
/**
@@ -26,9 +24,6 @@ public class AuthE2EController {
private final PasswordResetTokenRepository tokenRepository;
// Hidden from the OpenAPI spec — this endpoint must never appear in the generated api.ts
// even when the e2e profile is active alongside the dev profile during spec generation.
@Operation(hidden = true)
@GetMapping("/reset-token-for-test")
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())

View File

@@ -1,122 +0,0 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateCommentDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.CommentService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequiredArgsConstructor
@Slf4j
public class CommentController {
private final CommentService commentService;
private final UserService userService;
// ─── General document comments ────────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/comments")
public List<DocumentComment> getDocumentComments(@PathVariable UUID documentId) {
return commentService.getCommentsForDocument(documentId);
}
@PostMapping("/api/documents/{documentId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment postDocumentComment(
@PathVariable UUID documentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author);
}
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment replyToDocumentComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Annotation comments ──────────────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
public List<DocumentComment> getAnnotationComments(@PathVariable UUID annotationId) {
return commentService.getCommentsForAnnotation(annotationId);
}
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment postAnnotationComment(
@PathVariable UUID documentId,
@PathVariable UUID annotationId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author);
}
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment replyToAnnotationComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Edit and delete (shared) ─────────────────────────────────────────────
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment editComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser currentUser = resolveUser(authentication);
return commentService.editComment(documentId, commentId, dto.getContent(), currentUser);
}
@DeleteMapping("/api/documents/{documentId}/comments/{commentId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
Authentication authentication) {
AppUser currentUser = resolveUser(authentication);
commentService.deleteComment(documentId, commentId, currentUser);
}
// ─── private helpers ──────────────────────────────────────────────────────
private AppUser resolveUser(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
return userService.findByUsername(authentication.getName());
} catch (Exception e) {
log.warn("Could not resolve user for comment: {}", e.getMessage());
return null;
}
}
}

View File

@@ -2,23 +2,16 @@ package org.raddatz.familienarchiv.controller;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import io.swagger.v3.oas.annotations.Parameter;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
@@ -30,7 +23,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.core.io.InputStreamResource;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
@@ -111,80 +103,6 @@ public class DocumentController {
}
}
// --- DELETE ---
@DeleteMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
documentService.deleteDocument(id);
return ResponseEntity.noContent().build();
}
// --- QUICK UPLOAD ---
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"application/pdf", "image/jpeg", "image/png", "image/tiff");
public record UploadError(String filename, String code) {}
public record QuickUploadResult(List<Document> created, List<Document> updated, List<UploadError> errors) {}
@PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequirePermission(Permission.WRITE_ALL)
public QuickUploadResult quickUpload(
@RequestPart(value = "files", required = false) List<MultipartFile> files) {
List<Document> created = new ArrayList<>();
List<Document> updated = new ArrayList<>();
List<UploadError> errors = new ArrayList<>();
if (files == null || files.isEmpty()) {
return new QuickUploadResult(created, updated, errors);
}
for (MultipartFile file : files) {
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
continue;
}
try {
DocumentService.StoreResult result = documentService.storeDocument(file);
if (result.isNew()) {
created.add(result.document());
} else {
updated.add(result.document());
}
} catch (Exception e) {
errors.add(new UploadError(file.getOriginalFilename(), "FILE_UPLOAD_FAILED"));
log.warn("Quick upload failed for file {}: {}", file.getOriginalFilename(), e.getMessage());
}
}
return new QuickUploadResult(created, updated, errors);
}
@GetMapping("/incomplete-count")
public Map<String, Long> getIncompleteCount() {
return Map.of("count", documentService.getIncompleteCount());
}
@GetMapping("/incomplete")
public List<IncompleteDocumentDTO> getIncomplete(
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) {
return documentService.findIncompleteDocuments(size);
}
@GetMapping("/incomplete/next")
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
return documentService.findNextIncompleteDocument(excludeId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.noContent().build());
}
@GetMapping("/recent-activity")
public ResponseEntity<List<Document>> getRecentActivity(
@RequestParam(defaultValue = "5") int size) {
return ResponseEntity.ok(documentService.getRecentActivity(size));
}
@GetMapping("/search")
public ResponseEntity<List<Document>> search(
@RequestParam(required = false) String q,
@@ -192,9 +110,8 @@ public class DocumentController {
@RequestParam(required = false) LocalDate to,
@RequestParam(required = false) UUID senderId,
@RequestParam(required = false) UUID receiverId,
@RequestParam(required = false, name = "tag") List<String> tags,
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status) {
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status));
@RequestParam(required = false, name = "tag") List<String> tags) {
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags));
}
// --- VERSIONS ---
@@ -212,7 +129,7 @@ public class DocumentController {
@GetMapping("/conversation")
public List<Document> getConversation(
@RequestParam UUID senderId,
@RequestParam(required = false) UUID receiverId,
@RequestParam UUID receiverId,
@RequestParam(required = false) LocalDate from,
@RequestParam(required = false) LocalDate to,
@RequestParam(defaultValue = "DESC") String dir) {

View File

@@ -2,15 +2,12 @@ package org.raddatz.familienarchiv.controller;
import java.util.stream.Collectors;
import jakarta.validation.ConstraintViolationException;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.server.ResponseStatusException;
import lombok.extern.slf4j.Slf4j;
@@ -33,26 +30,6 @@ public class GlobalExceptionHandler {
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
String message = ex.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
String message = "Invalid value '" + ex.getValue() + "' for parameter '" + ex.getName() + "'";
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode())
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
log.error("Unhandled exception", ex);

View File

@@ -1,105 +0,0 @@
package org.raddatz.familienarchiv.controller;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import io.swagger.v3.oas.annotations.Parameter;
import org.raddatz.familienarchiv.dto.NotificationDTO;
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.NotificationType;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.NotificationService;
import org.raddatz.familienarchiv.service.SseEmitterRegistry;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
import java.util.UUID;
@RestController
@RequiredArgsConstructor
@Validated
public class NotificationController {
private final NotificationService notificationService;
private final UserService userService;
private final SseEmitterRegistry sseEmitterRegistry;
// These endpoints are intentionally open to any authenticated user —
// they return and mutate only the current user's own notifications, scoped
// by the resolved user identity. No additional permission check is required.
@GetMapping(value = "/api/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(Authentication authentication) {
AppUser user = resolveUser(authentication);
return sseEmitterRegistry.register(user.getId());
}
@GetMapping("/api/notifications")
public Page<NotificationDTO> getNotifications(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") @Min(1) @Max(100) int size,
@Parameter(description = "Filter by notification type") @RequestParam(required = false) NotificationType type,
@Parameter(description = "Filter by read status") @RequestParam(required = false) Boolean read,
Authentication authentication) {
AppUser user = resolveUser(authentication);
PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return notificationService.getNotifications(user.getId(), type, read, pageable);
}
@GetMapping("/api/notifications/unread-count")
public Map<String, Long> countUnread(Authentication authentication) {
AppUser user = resolveUser(authentication);
return Map.of("count", notificationService.countUnread(user.getId()));
}
@PostMapping("/api/notifications/read-all")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void markAllRead(Authentication authentication) {
AppUser user = resolveUser(authentication);
notificationService.markAllRead(user.getId());
}
@PatchMapping("/api/notifications/{id}/read")
public NotificationDTO markOneRead(
@PathVariable UUID id,
Authentication authentication) {
AppUser user = resolveUser(authentication);
return notificationService.markRead(id, user.getId());
}
@GetMapping("/api/users/me/notification-preferences")
@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
public NotificationPreferenceDTO getPreferences(Authentication authentication) {
AppUser user = resolveUser(authentication);
return new NotificationPreferenceDTO(user.isNotifyOnReply(), user.isNotifyOnMention());
}
@PutMapping("/api/users/me/notification-preferences")
@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
public NotificationPreferenceDTO updatePreferences(
@RequestBody NotificationPreferenceDTO dto,
Authentication authentication) {
AppUser user = resolveUser(authentication);
AppUser updated = notificationService.updatePreferences(
user.getId(), dto.notifyOnReply(), dto.notifyOnMention());
return new NotificationPreferenceDTO(updated.isNotifyOnReply(), updated.isNotifyOnMention());
}
// ─── private helpers ──────────────────────────────────────────────────────
private AppUser resolveUser(Authentication authentication) {
return userService.findByUsername(authentication.getName());
}
}

View File

@@ -4,23 +4,16 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RestController
@@ -32,7 +25,7 @@ public class PersonController {
private final DocumentService documentService;
@GetMapping
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
public ResponseEntity<List<Person>> getPersons(@RequestParam(required = false) String q) {
return ResponseEntity.ok(personService.findAll(q));
}
@@ -59,20 +52,17 @@ public class PersonController {
}
@PostMapping
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
public ResponseEntity<Person> createPerson(@RequestBody Map<String, String> body) {
String firstName = body.get("firstName");
String lastName = body.get("lastName");
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
}
dto.setFirstName(dto.getFirstName().trim());
dto.setLastName(dto.getLastName().trim());
return ResponseEntity.ok(personService.createPerson(dto));
return ResponseEntity.ok(personService.createPerson(firstName.trim(), lastName.trim(), body.get("alias")));
}
@PutMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody PersonUpdateDTO dto) {
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
@@ -84,7 +74,6 @@ public class PersonController {
@PostMapping("/{id}/merge")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WRITE_ALL)
public void mergePerson(@PathVariable UUID id, @RequestBody Map<String, String> body) {
String targetIdStr = body.get("targetPersonId");
if (targetIdStr == null || targetIdStr.isBlank()) {

View File

@@ -1,25 +0,0 @@
package org.raddatz.familienarchiv.controller;
import org.raddatz.familienarchiv.dto.StatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class StatsController {
private final PersonRepository personRepository;
private final DocumentRepository documentRepository;
@GetMapping
public ResponseEntity<StatsDTO> getStats() {
return ResponseEntity.ok(new StatsDTO(personRepository.count(), documentRepository.count()));
}
}

View File

@@ -61,7 +61,6 @@ public class UserController {
}
@GetMapping("users/{id}")
@RequirePermission(Permission.ADMIN_USER)
public ResponseEntity<AppUser> getUser(@PathVariable UUID id) {
AppUser user = userService.getById(id);
user.setPassword(null);

View File

@@ -1,32 +0,0 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.MentionDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.UserSearchService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
public class UserSearchController {
private final UserSearchService userSearchService;
@GetMapping("/api/users/search")
public List<MentionDTO> search(@RequestParam(defaultValue = "") String q) {
return userSearchService.search(q).stream()
.map(this::toMentionDTO)
.toList();
}
private MentionDTO toMentionDTO(AppUser user) {
return new MentionDTO(user.getId(), user.getFirstName(), user.getLastName());
}
}

View File

@@ -1,17 +0,0 @@
package org.raddatz.familienarchiv.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateAnnotationDTO {
private int pageNumber;
private double x;
private double y;
private double width;
private double height;
private String color;
}

View File

@@ -1,13 +0,0 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Data
public class CreateCommentDTO {
private String content;
private List<UUID> mentionedUserIds = new ArrayList<>();
}

View File

@@ -17,5 +17,4 @@ public class DocumentUpdateDTO {
private UUID senderId;
private List<UUID> receiverIds;
private String tags;
private Boolean metadataComplete;
}

View File

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

View File

@@ -1,11 +0,0 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
public record MentionDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String firstName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String lastName
) {}

View File

@@ -1,19 +0,0 @@
package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.model.NotificationType;
import java.time.LocalDateTime;
import java.util.UUID;
public record NotificationDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) NotificationType type,
UUID documentId,
UUID referenceId,
UUID annotationId,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean read,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
String actorName,
String documentTitle
) {}

View File

@@ -1,3 +0,0 @@
package org.raddatz.familienarchiv.dto;
public record NotificationPreferenceDTO(boolean notifyOnReply, boolean notifyOnMention) {}

View File

@@ -1,19 +0,0 @@
package org.raddatz.familienarchiv.dto;
import java.util.UUID;
/**
* Projection returned by the /api/persons list endpoint.
* Includes document count to avoid N+1 queries in the UI.
* Uses interface projection for compatibility with native queries.
*/
public interface PersonSummaryDTO {
UUID getId();
String getFirstName();
String getLastName();
String getAlias();
Integer getBirthYear();
Integer getDeathYear();
String getNotes();
long getDocumentCount();
}

View File

@@ -1,17 +1,12 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class PersonUpdateDTO {
@Size(max = 100)
private String firstName;
@Size(max = 100)
private String lastName;
@Size(max = 200)
private String alias;
@Size(max = 5000)
private String notes;
private Integer birthYear;
private Integer deathYear;

View File

@@ -1,7 +0,0 @@
package org.raddatz.familienarchiv.dto;
/**
* Aggregate counts for the dashboard/persons stats bar.
*/
public record StatsDTO(long totalPersons, long totalDocuments) {
}

View File

@@ -8,10 +8,6 @@ package org.raddatz.familienarchiv.exception;
*/
public enum ErrorCode {
// --- Persons ---
/** A person with the given ID does not exist. 404 */
PERSON_NOT_FOUND,
// --- Documents ---
/** A document with the given ID does not exist. 404 */
DOCUMENT_NOT_FOUND,
@@ -21,8 +17,6 @@ public enum ErrorCode {
FILE_NOT_FOUND,
/** An error occurred while uploading a file to object storage. 500 */
FILE_UPLOAD_FAILED,
/** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */
UNSUPPORTED_FILE_TYPE,
// --- Users ---
/** A user with the given ID or username does not exist. 404 */
@@ -44,20 +38,6 @@ public enum ErrorCode {
/** The password-reset token is missing, expired, or already used. 400 */
INVALID_RESET_TOKEN,
// --- Annotations ---
/** The annotation with the given ID does not exist. 404 */
ANNOTATION_NOT_FOUND,
/** The new annotation overlaps an existing one on the same page. 409 */
ANNOTATION_OVERLAP,
// --- Comments ---
/** The comment with the given ID does not exist. 404 */
COMMENT_NOT_FOUND,
// --- Notifications ---
/** The notification with the given ID does not exist. 404 */
NOTIFICATION_NOT_FOUND,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR,

View File

@@ -51,16 +51,6 @@ public class AppUser {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen
@Column(nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean notifyOnReply = false;
@Column(nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean notifyOnMention = false;
// Ein User kann in mehreren Gruppen sein
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))

View File

@@ -39,10 +39,6 @@ public class Document {
@Column(name = "content_type")
private String contentType;
// SHA-256 hash of the uploaded file — used to link annotations to a file version
@Column(name = "file_hash", length = 64)
private String fileHash;
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
@Column(name = "original_filename", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@@ -86,11 +82,6 @@ public class Document {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime updatedAt;
@Column(name = "metadata_complete", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private boolean metadataComplete = false;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
@Builder.Default

View File

@@ -1,62 +0,0 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "document_annotations")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DocumentAnnotation {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "document_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID documentId;
@Column(name = "page_number", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private int pageNumber;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double x;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double y;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double width;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double height;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String color;
@Column(name = "file_hash", length = 64)
private String fileHash;
@Column(name = "created_by")
private UUID createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
}

View File

@@ -1,82 +0,0 @@
package org.raddatz.familienarchiv.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.raddatz.familienarchiv.dto.MentionDTO;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "document_comments")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DocumentComment {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "document_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID documentId;
@Column(name = "annotation_id")
private UUID annotationId;
@Column(name = "parent_id")
private UUID parentId;
@Column(name = "author_id")
private UUID authorId;
@Column(name = "author_name", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String authorName;
@Column(nullable = false, columnDefinition = "TEXT")
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
@Column(name = "created_at", nullable = false, updatable = false)
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
@UpdateTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime updatedAt;
// Populated by the service — not stored in the database
@Transient
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private List<DocumentComment> replies = new ArrayList<>();
// JPA join table for structured mention references — not serialized directly
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "comment_mentions",
joinColumns = @JoinColumn(name = "comment_id"),
inverseJoinColumns = @JoinColumn(name = "user_id")
)
@JsonIgnore
@Builder.Default
private List<AppUser> mentions = new ArrayList<>();
// Populated by CommentService before serialization — not persisted.
@Transient
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private List<MentionDTO> mentionDTOs = new ArrayList<>();
}

View File

@@ -1,55 +0,0 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "notifications")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recipient_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private AppUser recipient;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private NotificationType type;
@Column(name = "document_id")
private UUID documentId;
@Column(name = "reference_id")
private UUID referenceId;
@Column(name = "annotation_id")
private UUID annotationId;
@Column(nullable = false)
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private boolean read = false;
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
@Column(name = "actor_name")
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String actorName;
}

View File

@@ -1,6 +0,0 @@
package org.raddatz.familienarchiv.model;
public enum NotificationType {
REPLY,
MENTION
}

View File

@@ -1,19 +0,0 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface AnnotationRepository extends JpaRepository<DocumentAnnotation, UUID> {
List<DocumentAnnotation> findByDocumentId(UUID documentId);
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
List<DocumentAnnotation> findByDocumentIdAndFileHashIsNull(UUID documentId);
}

View File

@@ -1,13 +1,10 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.AppUser;
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 org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -15,9 +12,4 @@ import java.util.UUID;
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
Optional<AppUser> findByUsername(String username);
Optional<AppUser> findByEmail(String email);
@Query("SELECT u FROM AppUser u WHERE " +
"LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) " +
"OR LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%'))")
List<AppUser> searchByNameOrUsername(@Param("q") String q, Pageable pageable);
}

View File

@@ -1,16 +0,0 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface CommentRepository extends JpaRepository<DocumentComment, UUID> {
List<DocumentComment> findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId);
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
List<DocumentComment> findByParentId(UUID parentId);
}

View File

@@ -2,8 +2,6 @@ package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@@ -12,9 +10,7 @@ import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -25,9 +21,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
Optional<Document> findByOriginalFilename(String originalFilename);
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
Optional<Document> findFirstByOriginalFilename(String originalFilename);
// Findet alle Dokumente mit einem bestimmten Status
// z.B. um alle offenen "PLACEHOLDER" zu finden
List<Document> findByStatus(DocumentStatus status);
@@ -44,25 +37,14 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
List<Document> findDocumentsWithoutVersions();
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
@Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids")
List<Object[]> findIdAndTitleByIdIn(@Param("ids") Collection<UUID> ids);
long countByMetadataCompleteFalse();
List<Document> findByMetadataCompleteFalse(Sort sort);
Page<Document> findByMetadataCompleteFalse(Pageable pageable);
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
@Query("SELECT DISTINCT d FROM Document d " +
"JOIN d.receivers r " +
"WHERE " +
// Logik: (Sender A an Empfänger B) ODER (Sender B an Empfänger A)
"((d.sender.id = :person1 AND r.id = :person2) " +
" OR " +
" (d.sender.id = :person2 AND r.id = :person1)) " +
// UND das Datum stimmt
"AND d.documentDate BETWEEN :from AND :to")
List<Document> findConversation(
@Param("person1") UUID person1,
@@ -71,14 +53,4 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@Param("to") LocalDate to,
Sort sort);
@Query("SELECT DISTINCT d FROM Document d " +
"LEFT JOIN d.receivers r " +
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
"AND d.documentDate BETWEEN :from AND :to")
List<Document> findSinglePersonCorrespondence(
@Param("personId") UUID personId,
@Param("from") LocalDate from,
@Param("to") LocalDate to,
Sort sort);
}

View File

@@ -7,7 +7,6 @@ import java.util.List;
import java.util.UUID;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Tag;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils;
@@ -56,11 +55,6 @@ public class DocumentSpecifications {
};
}
// Filtert nach Status
public static Specification<Document> hasStatus(DocumentStatus status) {
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
}
// Filtert nach Schlagworten (UND-Verknüpfung)
public static Specification<Document> hasTags(List<String> tags) {
return (root, query, cb) -> {

View File

@@ -1,32 +0,0 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.Notification;
import org.raddatz.familienarchiv.model.NotificationType;
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.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.UUID;
public interface NotificationRepository extends JpaRepository<Notification, UUID> {
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
Page<Notification> findByRecipientIdAndTypeOrderByCreatedAtDesc(
UUID recipientId, NotificationType type, Pageable pageable);
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
UUID recipientId, NotificationType type, Pageable pageable);
Page<Notification> findByRecipientIdAndReadFalseOrderByCreatedAtDesc(
UUID recipientId, Pageable pageable);
long countByRecipientIdAndReadFalse(UUID recipientId);
@Modifying
@Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId")
void markAllReadByRecipientId(@Param("userId") UUID userId);
}

View File

@@ -4,7 +4,6 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.model.Person;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
@@ -29,36 +28,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
// Exact first+last name match, used for filename-based sender lookup
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
// --- PersonSummaryDTO with document count ---
@Query(value = """
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)
List<PersonSummaryDTO> findAllWithDocumentCount();
@Query(value = """
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
FROM persons p
WHERE LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)
List<PersonSummaryDTO> searchWithDocumentCount(@Param("query") String query);
// --- Correspondent queries ---
@Query(value = """

View File

@@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.security;
public enum Permission {
READ_ALL,
WRITE_ALL,
ANNOTATE_ALL,
ADMIN,
ADMIN_USER,
ADMIN_TAG,

View File

@@ -23,7 +23,7 @@ public class PermissionAspect {
RequirePermission permission = getAnnotation(joinPoint);
if (permission != null) {
validateUserAccess(permission.value()); // value() is now Permission[]
validateUserAccess(permission.value());
}
return joinPoint.proceed();
@@ -43,23 +43,18 @@ public class PermissionAspect {
return joinPoint.getTarget().getClass().getAnnotation(RequirePermission.class);
}
private void validateUserAccess(Permission[] requiredPerms) {
private void validateUserAccess(Permission requiredPerm) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw DomainException.unauthorized("Not authenticated");
}
boolean hasAny = auth.getAuthorities().stream()
.anyMatch(a -> {
for (Permission p : requiredPerms) {
if (a.getAuthority().equals(p.name())) return true;
}
return false;
});
boolean hasPermission = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals(requiredPerm.name()));
if (!hasAny) {
throw DomainException.forbidden("Missing required permission");
if (!hasPermission) {
throw DomainException.forbidden("Missing required permission: " + requiredPerm.name());
}
}
}

View File

@@ -8,5 +8,5 @@ import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
Permission[] value(); // one or more — user needs any of the listed permissions
Permission value(); // e.g. "ADMIN" or "WRITE_ALL"
}

View File

@@ -1,83 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class AnnotationService {
private final AnnotationRepository annotationRepository;
public List<DocumentAnnotation> listAnnotations(UUID documentId) {
return annotationRepository.findByDocumentId(documentId);
}
@Transactional
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
List<DocumentAnnotation> existing =
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
boolean overlaps = existing.stream().anyMatch(a -> overlaps(a, dto));
if (overlaps) {
throw DomainException.conflict(
ErrorCode.ANNOTATION_OVERLAP, "Annotation overlaps an existing one on this page");
}
DocumentAnnotation annotation = DocumentAnnotation.builder()
.documentId(documentId)
.pageNumber(dto.getPageNumber())
.x(dto.getX())
.y(dto.getY())
.width(dto.getWidth())
.height(dto.getHeight())
.color(dto.getColor())
.fileHash(fileHash)
.createdBy(userId)
.build();
return annotationRepository.save(annotation);
}
@Transactional
public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) {
DocumentAnnotation annotation = annotationRepository
.findByIdAndDocumentId(annotationId, documentId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.ANNOTATION_NOT_FOUND, "Annotation not found: " + annotationId));
if (userId == null || !userId.equals(annotation.getCreatedBy())) {
throw DomainException.forbidden("Only the annotation author can delete it");
}
annotationRepository.delete(annotation);
}
@Transactional
public void backfillAnnotationFileHashForDocument(UUID documentId, String fileHash) {
annotationRepository.findByDocumentIdAndFileHashIsNull(documentId).forEach(a -> {
a.setFileHash(fileHash);
annotationRepository.save(a);
});
}
// ─── private helpers ──────────────────────────────────────────────────────
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {
double ex2 = existing.getX() + existing.getWidth();
double ey2 = existing.getY() + existing.getHeight();
double dx2 = dto.getX() + dto.getWidth();
double dy2 = dto.getY() + dto.getHeight();
return existing.getX() < dx2 && ex2 > dto.getX()
&& existing.getY() < dy2 && ey2 > dto.getY();
}
}

View File

@@ -1,160 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.MentionDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.repository.CommentRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class CommentService {
private final CommentRepository commentRepository;
private final UserService userService;
private final NotificationService notificationService;
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
List<DocumentComment> roots =
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
return withRepliesAndMentions(roots);
}
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
return withRepliesAndMentions(roots);
}
@Transactional
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
List<UUID> mentionedUserIds, AppUser author) {
DocumentComment comment = DocumentComment.builder()
.documentId(documentId)
.annotationId(annotationId)
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
saveMentions(comment, mentionedUserIds);
DocumentComment saved = commentRepository.save(comment);
withMentionDTOs(saved);
notificationService.notifyMentions(mentionedUserIds, saved);
return saved;
}
@Transactional
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content,
List<UUID> mentionedUserIds, AppUser author) {
DocumentComment target = commentRepository.findById(commentId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
UUID rootId = target.getParentId() != null ? target.getParentId() : target.getId();
DocumentComment root = commentRepository.findById(rootId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + rootId));
DocumentComment reply = DocumentComment.builder()
.documentId(documentId)
.annotationId(root.getAnnotationId())
.parentId(root.getId())
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
saveMentions(reply, mentionedUserIds);
DocumentComment saved = commentRepository.save(reply);
withMentionDTOs(saved);
Set<UUID> participantIds = collectParticipantIds(root);
participantIds.remove(author.getId());
notificationService.notifyReply(saved, participantIds);
notificationService.notifyMentions(mentionedUserIds, saved);
return saved;
}
@Transactional
public DocumentComment editComment(UUID documentId, UUID commentId, String content, AppUser currentUser) {
DocumentComment comment = findComment(documentId, commentId);
if (!currentUser.getId().equals(comment.getAuthorId())) {
throw DomainException.forbidden("Only the comment author can edit it");
}
comment.setContent(content);
return commentRepository.save(comment);
}
@Transactional
public void deleteComment(UUID documentId, UUID commentId, AppUser currentUser) {
DocumentComment comment = findComment(documentId, commentId);
boolean isAuthor = currentUser.getId().equals(comment.getAuthorId());
boolean isAdmin = currentUser.hasPermission("ADMIN");
if (!isAuthor && !isAdmin) {
throw DomainException.forbidden("Only the comment author or an admin can delete it");
}
commentRepository.delete(comment);
}
public List<DocumentComment> findReplies(UUID parentId) {
return commentRepository.findByParentId(parentId);
}
// ─── private helpers ──────────────────────────────────────────────────────
private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) {
roots.forEach(root -> {
List<DocumentComment> replies = commentRepository.findByParentId(root.getId());
replies.forEach(this::withMentionDTOs);
root.setReplies(replies);
withMentionDTOs(root);
});
return roots;
}
private void saveMentions(DocumentComment comment, List<UUID> mentionedUserIds) {
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
List<AppUser> users = userService.findAllById(mentionedUserIds);
comment.setMentions(users);
}
private void withMentionDTOs(DocumentComment comment) {
List<MentionDTO> dtos = comment.getMentions().stream()
.map(u -> new MentionDTO(u.getId(), u.getFirstName(), u.getLastName()))
.toList();
comment.setMentionDTOs(dtos);
}
private Set<UUID> collectParticipantIds(DocumentComment root) {
Set<UUID> ids = new LinkedHashSet<>();
if (root.getAuthorId() != null) ids.add(root.getAuthorId());
commentRepository.findByParentId(root.getId())
.forEach(reply -> {
if (reply.getAuthorId() != null) ids.add(reply.getAuthorId());
});
return ids;
}
private DocumentComment findComment(UUID documentId, UUID commentId) {
return commentRepository.findById(commentId)
.filter(c -> documentId.equals(c.getDocumentId()))
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
}
private String resolveAuthorName(AppUser author) {
String first = author.getFirstName();
String last = author.getLastName();
if ((first == null || first.isBlank()) && (last == null || last.isBlank())) {
return author.getUsername();
}
return ((first != null ? first : "") + " " + (last != null ? last : "")).strip();
}
}

View File

@@ -4,13 +4,11 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.raddatz.familienarchiv.exception.DomainException;
@@ -20,16 +18,11 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -45,64 +38,42 @@ public class DocumentService {
private final FileService fileService;
private final TagService tagService;
private final DocumentVersionService documentVersionService;
private final AnnotationService annotationService;
public record StoreResult(Document document, boolean isNew) {}
public Map<UUID, String> findTitlesByIds(Collection<UUID> ids) {
if (ids.isEmpty()) return Map.of();
Map<UUID, String> titles = new HashMap<>();
for (Object[] row : documentRepository.findIdAndTitleByIdIn(ids)) {
titles.put((UUID) row[0], (String) row[1]);
}
return titles;
}
/**
* Lädt eine Datei hoch.
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
* - Wenn JA: Aktualisiert Status und verknüpft Datei — isNew = false.
* - Wenn NEIN: Erstellt neuen Eintrag — isNew = true.
* - Wenn JA: Aktualisiert Status und verknüpft Datei.
* - Wenn NEIN: Erstellt neuen Eintrag (wartet auf Metadaten).
*/
@Transactional
public StoreResult storeDocument(MultipartFile file) throws IOException {
public Document storeDocument(MultipartFile file) throws IOException {
String originalFilename = file.getOriginalFilename();
// 1. Check for existing record (findFirst to survive duplicate filenames in the DB)
Optional<Document> existingDoc = documentRepository.findFirstByOriginalFilename(originalFilename);
boolean isNew = existingDoc.isEmpty();
// 1. Check for existing record
Optional<Document> existingDoc = documentRepository.findByOriginalFilename(originalFilename);
Document document;
if (existingDoc.isPresent()) {
document = existingDoc.get();
} else {
// New uploads from the drop zone always start as incomplete
ParsedFilename parsed = parseFilenameData(originalFilename);
Person sender = (parsed != null)
? personService.findByName(parsed.firstName(), parsed.lastName()).orElse(null)
: null;
document = Document.builder()
.originalFilename(originalFilename)
.title(parsed != null ? parsed.title() : stripExtension(originalFilename))
.documentDate(parsed != null ? parsed.date() : null)
.sender(sender)
.title(originalFilename)
.status(DocumentStatus.UPLOADED)
.metadataComplete(false)
.build();
}
// 2. Delegate Storage to FileService
FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
String s3Key = fileService.uploadFile(file, originalFilename);
// 3. Update Database
document.setFilePath(upload.s3Key());
document.setFileHash(upload.fileHash());
document.setFilePath(s3Key);
document.setContentType(file.getContentType());
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
document.setStatus(DocumentStatus.UPLOADED);
}
return new StoreResult(documentRepository.save(document), isNew);
return documentRepository.save(document);
}
@Transactional
@@ -111,31 +82,15 @@ public class DocumentService {
? file.getOriginalFilename()
: (dto.getTitle() != null ? dto.getTitle() : "Unbenanntes Dokument");
// If the caller explicitly sets metadataComplete, use it.
// Otherwise apply heuristic: complete if at least one key field is present.
boolean metadataComplete;
if (dto.getMetadataComplete() != null) {
metadataComplete = dto.getMetadataComplete();
} else {
metadataComplete = dto.getDocumentDate() != null
|| dto.getSenderId() != null
|| (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty());
}
String titleToUse = (dto.getTitle() != null && !dto.getTitle().isBlank())
? dto.getTitle()
: titleFromFilename(filename);
Document doc = Document.builder()
.originalFilename(filename)
.title(titleToUse)
.title(dto.getTitle())
.documentDate(dto.getDocumentDate())
.location(dto.getLocation())
.documentLocation(dto.getDocumentLocation())
.transcription(dto.getTranscription())
.summary(dto.getSummary())
.status(DocumentStatus.PLACEHOLDER)
.metadataComplete(metadataComplete)
.build();
doc = documentRepository.save(doc);
@@ -165,9 +120,8 @@ public class DocumentService {
// Datei
if (file != null && !file.isEmpty()) {
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
String s3Key = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(s3Key);
doc.setContentType(file.getContentType());
doc.setStatus(DocumentStatus.UPLOADED);
}
@@ -214,16 +168,14 @@ public class DocumentService {
doc.getReceivers().clear(); // Alle entfernen
}
// 3b. metadataComplete — only update when explicitly set in the DTO
if (dto.getMetadataComplete() != null) {
doc.setMetadataComplete(dto.getMetadataComplete());
}
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
if (newFile != null && !newFile.isEmpty()) {
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
// Alte Datei könnte man hier theoretisch löschen (optional)
// Neue Datei hochladen
String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(s3Key);
doc.setOriginalFilename(newFile.getOriginalFilename());
doc.setContentType(newFile.getContentType());
doc.setStatus(DocumentStatus.UPLOADED);
@@ -272,24 +224,16 @@ public class DocumentService {
return documentRepository.save(doc);
}
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
public List<Document> getRecentActivity(int size) {
return documentRepository.findAll(
PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
).getContent();
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, DocumentStatus status) {
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags) {
Specification<Document> spec = Specification.where(hasText(text))
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(hasTags(tags))
.and(hasStatus(status));
.and(hasTags(tags));
// Neueste zuerst (nach Erstellungsdatum)
return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt"));
// Immer sortiert nach Datum
return documentRepository.findAll(spec, Sort.by(Sort.Direction.ASC, "documentDate"));
}
// 2. SPEZIALITÄT: Der Schriftwechsel
@@ -328,37 +272,9 @@ public class DocumentService {
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
LocalDate dateTo = (to != null) ? to : LocalDate.now();
if (receiverId == null) {
return documentRepository.findSinglePersonCorrespondence(senderId, dateFrom, dateTo, sort);
}
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
}
public long getIncompleteCount() {
return documentRepository.countByMetadataCompleteFalse();
}
public List<IncompleteDocumentDTO> findIncompleteDocuments(int size) {
PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
return documentRepository.findByMetadataCompleteFalse(pageable)
.stream()
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle()))
.toList();
}
public Optional<Document> findNextIncompleteDocument(UUID currentId) {
return documentRepository.findFirstByMetadataCompleteFalseAndIdNot(
currentId, Sort.by(Sort.Direction.DESC, "createdAt"));
}
@Transactional
public void deleteDocument(UUID id) {
if (!documentRepository.existsById(id)) {
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
}
documentRepository.deleteById(id);
}
@Transactional
public void deleteTagCascading(UUID tagId) {
documentRepository.findByTags_Id(tagId).forEach(doc -> {
@@ -367,120 +283,4 @@ public class DocumentService {
});
tagService.delete(tagId);
}
@Transactional
public int backfillFileHashes() {
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
int count = 0;
for (Document doc : docs) {
try {
byte[] bytes = fileService.downloadFileBytes(doc.getFilePath());
String hash = sha256Hex(bytes);
doc.setFileHash(hash);
documentRepository.save(doc);
annotationService.backfillAnnotationFileHashForDocument(doc.getId(), hash);
count++;
} catch (Exception e) {
log.warn("Failed to backfill hash for document {}: {}", doc.getId(), e.getMessage());
}
}
return count;
}
// ─── private helpers ──────────────────────────────────────────────────────
private static String stripExtension(String filename) {
if (filename == null) return null;
int dot = filename.lastIndexOf('.');
return dot > 0 ? filename.substring(0, dot) : filename;
}
private record ParsedFilename(LocalDate date, String firstName, String lastName) {
String title() {
String dateDisplay = String.format("%02d.%02d.%d",
date.getDayOfMonth(), date.getMonthValue(), date.getYear());
return firstName + " " + lastName + " (" + dateDisplay + ")";
}
}
/**
* Parses a structured filename into its date and name components.
*
* Algorithm: split stem on "_", identify the date token (first or last segment),
* treat the outermost remaining segment as firstName, rest as lastName parts.
* Compound last names (e.g. "de_Gruyter") are supported naturally.
* Returns null for unrecognised filenames.
*
* Examples:
* 18881025_de_Gruyter_Walter.pdf → date=1888-10-25, firstName=Walter, lastName=de Gruyter
* 1965-03-12_Mueller_Hans.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller
* Mueller_Hans_19650312.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller
*/
private static ParsedFilename parseFilenameData(String filename) {
if (filename == null) return null;
int dot = filename.lastIndexOf('.');
if (dot < 0) return null;
String stem = filename.substring(0, dot);
String[] parts = stem.split("_", -1);
if (parts.length < 3) return null;
String dateIso;
String[] nameParts;
String dateFromFirst = tryParseDate(parts[0]);
if (dateFromFirst != null) {
dateIso = dateFromFirst;
nameParts = Arrays.copyOfRange(parts, 1, parts.length);
} else {
String dateFromLast = tryParseDate(parts[parts.length - 1]);
if (dateFromLast == null) return null;
dateIso = dateFromLast;
nameParts = Arrays.copyOfRange(parts, 0, parts.length - 1);
}
if (nameParts.length < 2) return null;
for (String p : nameParts) {
if (!p.matches("\\p{L}+")) return null;
}
String firstName = nameParts[nameParts.length - 1];
String lastName = String.join(" ", Arrays.copyOfRange(nameParts, 0, nameParts.length - 1));
return new ParsedFilename(LocalDate.parse(dateIso), firstName, lastName);
}
// Used by tests and as a public utility; delegates to parseFilenameData.
static String titleFromFilename(String filename) {
if (filename == null) return null;
ParsedFilename parsed = parseFilenameData(filename);
return parsed != null ? parsed.title() : stripExtension(filename);
}
private static String tryParseDate(String s) {
if (s.matches("\\d{4}-\\d{2}-\\d{2}")) {
int m = Integer.parseInt(s.substring(5, 7));
int d = Integer.parseInt(s.substring(8, 10));
if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s;
} else if (s.matches("\\d{8}")) {
int m = Integer.parseInt(s.substring(4, 6));
int d = Integer.parseInt(s.substring(6, 8));
if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
return s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6, 8);
}
return null;
}
private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(bytes);
StringBuilder sb = new StringBuilder(64);
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}

View File

@@ -13,9 +13,6 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.core.io.InputStreamResource;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
@Service
@@ -32,14 +29,10 @@ public class FileService {
}
/**
* Uploads a file to S3/MinIO.
* Returns an {@link UploadResult} containing the S3 key and the SHA-256
* hash of the file content. The hash is used to link annotations to the
* specific file version they were created against.
* Uploads a file to S3/MinIO and returns the generated object key.
*/
public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
byte[] bytes = file.getBytes();
String fileHash = sha256Hex(bytes);
public String uploadFile(MultipartFile file, String originalFilename) throws IOException {
// Generate secure unique path: "documents/UUID_filename"
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
try {
@@ -49,10 +42,11 @@ public class FileService {
.contentType(file.getContentType())
.build();
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
s3Client.putObject(putObjectRequest,
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
return new UploadResult(s3Key, fileHash);
log.info("Uploaded file to S3: {}", s3Key);
return s3Key;
} catch (S3Exception e) {
log.error("S3 Upload Error", e);
throw new IOException("Failed to upload file to storage", e);
@@ -64,72 +58,32 @@ public class FileService {
* Returns a wrapper containing the stream and content type.
*/
public S3FileDownload downloadFile(String s3Key) {
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
String contentType = s3Object.response().contentType();
if (contentType == null || contentType.isBlank()) {
contentType = "application/octet-stream";
}
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new RuntimeException("Storage Error: " + e.getMessage());
// Use whatever content type S3 has stored (set at upload time)
String contentType = s3Object.response().contentType();
if (contentType == null || contentType.isBlank()) {
contentType = "application/octet-stream";
}
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new RuntimeException("Storage Error: " + e.getMessage());
}
/**
* Downloads a file from S3/MinIO and returns its raw bytes.
* Used for hash backfill — callers are responsible for not calling this on large files unnecessarily.
*/
public byte[] downloadFileBytes(String s3Key) throws IOException {
try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName)
.key(s3Key)
.build();
try (InputStream in = s3Client.getObject(getObjectRequest)) {
return in.readAllBytes();
}
} catch (NoSuchKeyException e) {
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
} catch (S3Exception e) {
throw new IOException("Failed to download file from storage: " + e.getMessage(), e);
}
}
// ─── private helpers ──────────────────────────────────────────────────────
private static String sha256Hex(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(bytes);
StringBuilder sb = new StringBuilder(64);
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
// ─── result types ─────────────────────────────────────────────────────────
/** Carries the S3 object key and the content hash back to the caller. */
public record UploadResult(String s3Key, String fileHash) {}
/** Carries the download stream and content type. */
}
// Helper Record to carry the stream and metadata back to the controller
public record S3FileDownload(InputStreamResource resource, String contentType) {}
// Custom Exception
public static class StorageFileNotFoundException extends RuntimeException {
public StorageFileNotFoundException(String message) { super(message); }
}

View File

@@ -312,9 +312,6 @@ public class MassImportService {
.originalFilename(originalFilename)
.build());
// Heuristic: mark as complete if at least one key field is present in the spreadsheet row
boolean metadataComplete = date != null || !senderRaw.isBlank() || !receiversRaw.isBlank();
doc.setTitle(buildTitle(index, date, location));
doc.setFilePath(s3Key);
doc.setContentType(contentType);
@@ -328,7 +325,6 @@ public class MassImportService {
doc.setSender(sender);
doc.getReceivers().addAll(receivers);
if (tag != null) doc.getTags().add(tag);
doc.setMetadataComplete(metadataComplete);
documentRepository.save(doc);
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);

View File

@@ -1,210 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.NotificationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.Notification;
import org.raddatz.familienarchiv.model.NotificationType;
import org.raddatz.familienarchiv.repository.NotificationRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationService {
private final NotificationRepository notificationRepository;
private final UserService userService;
private final DocumentService documentService;
private final Optional<JavaMailSender> mailSender;
private final SseEmitterRegistry sseEmitterRegistry;
@Value("${app.mail.from:noreply@familienarchiv.local}")
private String mailFrom;
@Value("${app.base-url:http://localhost:3000}")
private String baseUrl;
/**
* Creates REPLY notifications for all participants in the thread, excluding the replier.
* Runs in a separate transaction so a notification failure cannot roll back the parent comment.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void notifyReply(DocumentComment reply, Set<UUID> participantIds) {
if (participantIds.isEmpty()) return;
List<AppUser> recipients = userService.findAllById(participantIds);
for (AppUser recipient : recipients) {
Notification notification = Notification.builder()
.recipient(recipient)
.type(NotificationType.REPLY)
.documentId(reply.getDocumentId())
.referenceId(reply.getId())
.annotationId(reply.getAnnotationId())
.actorName(reply.getAuthorName())
.build();
saveAndPush(notification);
if (recipient.isNotifyOnReply()) {
sendNotificationEmail(recipient, reply, NotificationType.REPLY);
}
}
}
/**
* Creates MENTION notifications for each mentioned user.
* Runs in a separate transaction so a notification failure cannot roll back the parent comment.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment) {
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
List<AppUser> recipients = userService.findAllById(mentionedUserIds);
for (AppUser recipient : recipients) {
Notification notification = Notification.builder()
.recipient(recipient)
.type(NotificationType.MENTION)
.documentId(comment.getDocumentId())
.referenceId(comment.getId())
.annotationId(comment.getAnnotationId())
.actorName(comment.getAuthorName())
.build();
saveAndPush(notification);
if (recipient.isNotifyOnMention()) {
sendNotificationEmail(recipient, comment, NotificationType.MENTION);
}
}
}
public Page<NotificationDTO> getNotifications(UUID userId, NotificationType type, Boolean read, Pageable pageable) {
Page<Notification> page;
if (type != null && Boolean.FALSE.equals(read)) {
page = notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable);
} else if (type != null) {
page = notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable);
} else if (Boolean.FALSE.equals(read)) {
page = notificationRepository.findByRecipientIdAndReadFalseOrderByCreatedAtDesc(userId, pageable);
} else {
page = notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable);
}
return mapWithDocumentTitles(page);
}
private Page<NotificationDTO> mapWithDocumentTitles(Page<Notification> page) {
Set<UUID> documentIds = page.getContent().stream()
.map(Notification::getDocumentId)
.filter(id -> id != null)
.collect(Collectors.toSet());
Map<UUID, String> titles = documentService.findTitlesByIds(documentIds);
return page.map(n -> toDTO(n, titles));
}
public long countUnread(UUID userId) {
return notificationRepository.countByRecipientIdAndReadFalse(userId);
}
@Transactional
public void markAllRead(UUID userId) {
notificationRepository.markAllReadByRecipientId(userId);
}
@Transactional
public NotificationDTO markRead(UUID notificationId, UUID userId) {
Notification notification = notificationRepository.findById(notificationId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notificationId));
if (!notification.getRecipient().getId().equals(userId)) {
throw DomainException.forbidden("Notification belongs to a different user");
}
notification.setRead(true);
return toDTO(notificationRepository.save(notification), Map.of());
}
@Transactional
public AppUser updatePreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) {
return userService.updateNotificationPreferences(userId, notifyOnReply, notifyOnMention);
}
// ─── private helpers ──────────────────────────────────────────────────────
private void saveAndPush(Notification notification) {
Notification saved = notificationRepository.save(notification);
sseEmitterRegistry.send(saved.getRecipient().getId(), toDTO(saved, Map.of()));
}
private NotificationDTO toDTO(Notification n, Map<UUID, String> titles) {
return new NotificationDTO(
n.getId(),
n.getType(),
n.getDocumentId(),
n.getReferenceId(),
n.getAnnotationId(),
n.isRead(),
n.getCreatedAt(),
n.getActorName(),
n.getDocumentId() != null ? titles.get(n.getDocumentId()) : null
);
}
private void buildCommentPath(DocumentComment comment, StringBuilder sb) {
sb.append("?commentId=").append(comment.getId());
if (comment.getAnnotationId() != null) {
sb.append("&annotationId=").append(comment.getAnnotationId());
}
}
private void sendNotificationEmail(AppUser recipient, DocumentComment comment, NotificationType type) {
if (mailSender.isEmpty()) {
log.warn("Mail sender not configured — skipping notification email to {}", recipient.getEmail());
return;
}
if (recipient.getEmail() == null || recipient.getEmail().isBlank()) return;
StringBuilder path = new StringBuilder("/documents/").append(comment.getDocumentId());
buildCommentPath(comment, path);
String link = baseUrl + path;
String subject = type == NotificationType.REPLY
? "Neue Antwort auf deinen Kommentar — Familienarchiv"
: "Du wurdest in einem Kommentar erwähnt — Familienarchiv";
String body = type == NotificationType.REPLY
? "Hallo,\n\njemand hat auf einen Kommentar geantwortet, an dem du beteiligt warst.\n\n"
+ "Zum Kommentar:\n" + link + "\n\nDein Familienarchiv-Team"
: "Hallo,\n\njemand hat dich in einem Kommentar erwähnt.\n\n"
+ "Zum Kommentar:\n" + link + "\n\nDein Familienarchiv-Team";
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailFrom);
message.setTo(recipient.getEmail());
message.setSubject(subject);
message.setText(body);
try {
mailSender.get().send(message);
} catch (MailException e) {
log.error("Failed to send notification email to {}: {}", recipient.getEmail(), e.getMessage());
}
}
}

View File

@@ -1,13 +1,9 @@
package org.raddatz.familienarchiv.service;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.http.HttpStatus;
@@ -23,19 +19,16 @@ public class PersonService {
private final PersonRepository personRepository;
public List<PersonSummaryDTO> findAll(String q) {
if (q == null) {
return personRepository.findAllWithDocumentCount();
public List<Person> findAll(String q) {
if (q != null && !q.isBlank()) {
return personRepository.searchByName(q);
}
if (q.isBlank()) {
return List.of();
}
return personRepository.searchWithDocumentCount(q.trim());
return personRepository.findAllByOrderByLastNameAscFirstNameAsc();
}
public Person getById(UUID id) {
return personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
}
public List<Person> findCorrespondents(UUID personId, String q) {
@@ -49,10 +42,6 @@ public class PersonService {
return personRepository.findAllById(ids);
}
public Optional<Person> findByName(String firstName, String lastName) {
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
}
@Transactional
public Person findOrCreateByAlias(String rawName) {
String alias = rawName.trim();
@@ -77,36 +66,12 @@ public class PersonService {
}
@Transactional
public Person createPerson(PersonUpdateDTO dto) {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = Person.builder()
.firstName(dto.getFirstName())
.lastName(dto.getLastName())
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthYear(dto.getBirthYear())
.deathYear(dto.getDeathYear())
.build();
return personRepository.save(person);
}
private void validateYears(Integer birthYear, Integer deathYear) {
if (birthYear != null && birthYear <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
}
if (deathYear != null && deathYear <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
}
if (birthYear != null && deathYear != null && birthYear > deathYear) {
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getBirthYear() != null && dto.getDeathYear() != null && dto.getBirthYear() > dto.getDeathYear()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
}
}
@Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
person.setFirstName(dto.getFirstName());
person.setLastName(dto.getLastName());
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
@@ -122,9 +87,9 @@ public class PersonService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Quelle und Ziel dürfen nicht identisch sein");
}
personRepository.findById(sourceId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Source person not found: " + sourceId));
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Quell-Person nicht gefunden"));
personRepository.findById(targetId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Target person not found: " + targetId));
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ziel-Person nicht gefunden"));
// Reassign sender references
personRepository.reassignSender(sourceId, targetId);

View File

@@ -1,36 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Component
@Slf4j
public class SseEmitterRegistry {
private final ConcurrentHashMap<UUID, SseEmitter> emitters = new ConcurrentHashMap<>();
public SseEmitter register(UUID userId) {
SseEmitter emitter = new SseEmitter(0L); // 0 = no timeout; EventSource reconnects automatically
emitters.put(userId, emitter);
emitter.onCompletion(() -> emitters.remove(userId, emitter));
emitter.onTimeout(() -> emitters.remove(userId, emitter));
emitter.onError(e -> emitters.remove(userId, emitter));
return emitter;
}
public void send(UUID userId, Object data) {
SseEmitter emitter = emitters.get(userId);
if (emitter == null) return;
try {
emitter.send(SseEmitter.event().name("notification").data(data));
} catch (IOException e) {
log.debug("SSE send failed for user {} — removing emitter", userId);
emitters.remove(userId, emitter);
}
}
}

View File

@@ -1,23 +0,0 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UserSearchService {
private static final int MAX_RESULTS = 10;
private final AppUserRepository userRepository;
public List<AppUser> search(String query) {
if (query == null || query.isBlank()) return List.of();
return userRepository.searchByNameOrUsername(query.trim(), PageRequest.of(0, MAX_RESULTS));
}
}

View File

@@ -18,7 +18,6 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@@ -79,18 +78,6 @@ public class UserService {
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
}
public List<AppUser> findAllById(Collection<UUID> ids) {
return userRepository.findAllById(ids);
}
@Transactional
public AppUser updateNotificationPreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) {
AppUser user = getById(userId);
user.setNotifyOnReply(notifyOnReply);
user.setNotifyOnMention(notifyOnMention);
return userRepository.save(user);
}
@Transactional
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
AppUser user = getById(userId);

View File

@@ -12,7 +12,6 @@ spring:
enabled: false # Managed explicitly via FlywayConfig bean
jpa:
open-in-view: false # Prevents holding DB connections for the full HTTP request lifecycle
hibernate:
ddl-auto: none
properties:

View File

@@ -1,14 +0,0 @@
CREATE TABLE document_annotations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
width DOUBLE PRECISION NOT NULL,
height DOUBLE PRECISION NOT NULL,
color VARCHAR(20) NOT NULL,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX ON document_annotations (document_id, page_number);

View File

@@ -1,7 +0,0 @@
-- Grant ANNOTATE_ALL to every group that already has ADMIN.
-- New installs get it via DataInitializer; this covers existing deployments.
INSERT INTO group_permissions (group_id, permission)
SELECT g.id, 'ANNOTATE_ALL'
FROM user_groups g
WHERE g.id IN (SELECT group_id FROM group_permissions WHERE permission = 'ADMIN')
AND g.id NOT IN (SELECT group_id FROM group_permissions WHERE permission = 'ANNOTATE_ALL');

View File

@@ -1,15 +0,0 @@
CREATE TABLE document_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
annotation_id UUID REFERENCES document_annotations(id) ON DELETE CASCADE,
parent_id UUID REFERENCES document_comments(id) ON DELETE CASCADE,
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
author_name VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_dc_document ON document_comments(document_id);
CREATE INDEX idx_dc_annotation ON document_comments(annotation_id);
CREATE INDEX idx_dc_parent ON document_comments(parent_id);

View File

@@ -1,7 +0,0 @@
-- Add content-based file hash to documents for annotation versioning
ALTER TABLE documents
ADD COLUMN file_hash VARCHAR(64);
-- Each annotation remembers which file version it was created against
ALTER TABLE document_annotations
ADD COLUMN file_hash VARCHAR(64);

View File

@@ -1,12 +0,0 @@
-- Add ON DELETE CASCADE to document_tags and document_receivers so that
-- deleting a document automatically removes its tag and receiver associations.
ALTER TABLE public.document_tags
DROP CONSTRAINT fkc99c5qjulwx9gru07yrhicgd2,
ADD CONSTRAINT fkc99c5qjulwx9gru07yrhicgd2
FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE;
ALTER TABLE public.document_receivers
DROP CONSTRAINT fks7t60twjgfmpeqcuc3g0fvjpm,
ADD CONSTRAINT fks7t60twjgfmpeqcuc3g0fvjpm
FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE;

View File

@@ -1,6 +0,0 @@
-- Add metadata_complete flag to documents.
-- Existing rows default to true (already reviewed before this feature existed).
-- New documents created via Java will receive false from the entity default.
ALTER TABLE documents
ADD COLUMN metadata_complete BOOLEAN NOT NULL DEFAULT TRUE;

View File

@@ -1,18 +0,0 @@
-- Notification preferences on the user record — no separate entity needed
ALTER TABLE users ADD COLUMN notify_on_reply BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN notify_on_mention BOOLEAN NOT NULL DEFAULT false;
-- In-app notifications
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipient_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(32) NOT NULL, -- 'REPLY' | 'MENTION'
document_id UUID,
reference_id UUID, -- commentId that triggered this notification
annotation_id UUID,
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT now(),
actor_name VARCHAR(255)
);
CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC);

View File

@@ -1,5 +0,0 @@
CREATE TABLE comment_mentions (
comment_id UUID NOT NULL REFERENCES document_comments(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (comment_id, user_id)
);

View File

@@ -1,25 +0,0 @@
package org.raddatz.familienarchiv;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.testcontainers.containers.PostgreSQLContainer;
import software.amazon.awssdk.services.s3.S3Client;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class ApplicationContextTest {
@MockitoBean
S3Client s3Client;
@Test
void contextLoads() {
// verifies that the Spring context starts successfully with all beans wired,
// Flyway migrations applied, and no configuration errors
}
}

View File

@@ -1,16 +0,0 @@
package org.raddatz.familienarchiv;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
@TestConfiguration(proxyBeanMethods = false)
public class PostgresContainerConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
}

View File

@@ -58,29 +58,4 @@ class AdminControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(1));
}
// ─── POST /api/admin/backfill-file-hashes ──────────────────────────────────
@Test
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ADMIN")
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
when(documentService.backfillFileHashes()).thenReturn(3);
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(3));
}
}

View File

@@ -1,205 +0,0 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(AnnotationController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class AnnotationControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean AnnotationService annotationService;
@MockitoBean DocumentService documentService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final String ANNOTATION_JSON =
"{\"pageNumber\":1,\"x\":0.1,\"y\":0.1,\"width\":0.2,\"height\":0.2,\"color\":\"#ff0000\"}";
// ─── GET /api/documents/{documentId}/annotations ──────────────────────────
@Test
void listAnnotations_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void listAnnotations_returns200_whenAuthenticated() throws Exception {
when(annotationService.listAnnotations(any())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
.andExpect(status().isOk());
}
// ─── POST /api/documents/{documentId}/annotations ─────────────────────────
@Test
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception {
UUID docId = UUID.randomUUID();
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isNoContent());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception {
UUID docId = UUID.randomUUID();
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.pageNumber").value(1));
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns409_whenOverlap() throws Exception {
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any()))
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isConflict());
}
// ─── DELETE /api/documents/{documentId}/annotations/{annotationId} ─────────
@Test
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isNoContent());
}
// ─── resolveUserId — unauthenticated / null user / exception branches ─────
@Test
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
// authentication == null → resolveUserId returns null
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_resolvesNullUserId_whenUserServiceThrows() throws Exception {
// findByUsername throws → catch block → resolveUserId returns null
UUID docId = UUID.randomUUID();
when(userService.findByUsername(any())).thenThrow(new RuntimeException("DB error"));
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_resolvesNullUserId_whenUserServiceReturnsNull() throws Exception {
// findByUsername returns null → user != null = false → resolveUserId returns null
UUID docId = UUID.randomUUID();
when(userService.findByUsername(any())).thenReturn(null);
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isCreated());
}
}

View File

@@ -1,282 +0,0 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CommentService;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(CommentController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class CommentControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean CommentService commentService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
private static final UUID DOC_ID = UUID.randomUUID();
private static final UUID ANN_ID = UUID.randomUUID();
private static final UUID COMMENT_ID = UUID.randomUUID();
// ─── GET /api/documents/{documentId}/comments ─────────────────────────────
@Test
void getDocumentComments_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getDocumentComments_returns200_whenAuthenticated() throws Exception {
when(commentService.getCommentsForDocument(any())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
.andExpect(status().isOk());
}
// ─── POST /api/documents/{documentId}/comments ────────────────────────────
@Test
void postDocumentComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void postDocumentComment_returns403_whenMissingPermission() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postDocumentComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.content").value("Test comment"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postDocumentComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
@Test
void replyToComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
.authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void replyToComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
.authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── PATCH /api/documents/{documentId}/comments/{commentId} ──────────────
@Test
void editComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void editComment_returns200_whenHasPermission() throws Exception {
DocumentComment updated = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk());
}
// ─── DELETE /api/documents/{documentId}/comments/{commentId} ─────────────
@Test
void deleteComment_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteComment_returns204_whenAuthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
.andExpect(status().isNoContent());
}
// ─── GET /api/documents/{documentId}/annotations/{annId}/comments ─────────
@Test
void getAnnotationComments_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getAnnotationComments_returns200_whenAuthenticated() throws Exception {
when(commentService.getCommentsForAnnotation(any())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void editComment_returns200_whenHasWriteAllPermission() throws Exception {
DocumentComment updated = DocumentComment.builder()
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isOk());
}
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
@Test
@WithMockUser
void postAnnotationComment_returns403_whenMissingPermission() throws Exception {
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postAnnotationComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.authorName("Hans").content("Test comment").build();
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments/{commentId}/replies ─
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToAnnotationComment_returns201_whenHasPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void replyToAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception {
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── resolveUser — exception branch ──────────────────────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postDocumentComment_stillSucceeds_whenUserServiceThrows() throws Exception {
// findByUsername throws → catch block in resolveUser → author null, saves anyway
when(userService.findByUsername(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());
}
}

View File

@@ -2,9 +2,7 @@ package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
@@ -23,13 +21,9 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
@@ -58,32 +52,13 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any()))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk());
}
@Test
@WithMockUser
void search_withStatusParam_passesItToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED)))
.thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk());
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED));
}
@Test
@WithMockUser
void search_withInvalidStatus_returns400() throws Exception {
mockMvc.perform(get("/api/documents/search").param("status", "INVALID"))
.andExpect(status().isBadRequest());
}
// ─── POST /api/documents ─────────────────────────────────────────────────
@Test
@@ -146,298 +121,6 @@ class DocumentControllerTest {
.andExpect(status().isOk());
}
// ─── DELETE /api/documents/{id} ──────────────────────────────────────────
@Test
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + id))
.andExpect(status().isNoContent());
}
// ─── POST /api/documents/quick-upload ────────────────────────────────────
@Test
void quickUpload_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(multipart("/api/documents/quick-upload"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
mockMvc.perform(multipart("/api/documents/quick-upload"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_returns200_withValidPdfFile() throws Exception {
Document doc = Document.builder()
.id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
when(documentService.storeDocument(any()))
.thenReturn(new DocumentService.StoreResult(doc, true));
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created[0].title").value("scan001"))
.andExpect(jsonPath("$.updated").isEmpty())
.andExpect(jsonPath("$.errors").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_placesDocumentInUpdated_whenFilenameAlreadyExists() throws Exception {
Document existing = Document.builder()
.id(UUID.randomUUID()).title("Alter Brief").originalFilename("scan001.pdf").build();
when(documentService.storeDocument(any()))
.thenReturn(new DocumentService.StoreResult(existing, false));
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
.andExpect(jsonPath("$.errors").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_skipsUnsupportedFileType_andReturnsError() throws Exception {
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
}
// ─── GET /api/documents/{id}/file ────────────────────────────────────────
@Test
@WithMockUser
void getDocumentFile_returns404_whenDocHasNoFilePath() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Brief").build(); // filePath == null
when(documentService.getDocumentById(id)).thenReturn(doc);
mockMvc.perform(get("/api/documents/" + id + "/file"))
.andExpect(status().isNotFound());
}
@Test
@WithMockUser
void getDocumentFile_returns200_withContentTypeFromDoc() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Brief")
.filePath("docs/brief.pdf").contentType("application/pdf")
.originalFilename("brief.pdf").build();
when(documentService.getDocumentById(id)).thenReturn(doc);
java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{1, 2, 3});
when(fileService.downloadFile("docs/brief.pdf"))
.thenReturn(new FileService.S3FileDownload(
new org.springframework.core.io.InputStreamResource(stream), "application/octet-stream"));
mockMvc.perform(get("/api/documents/" + id + "/file"))
.andExpect(status().isOk());
}
@Test
@WithMockUser
void getDocumentFile_returns200_withContentTypeFromStorage_whenDocContentTypeIsBlank() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Brief")
.filePath("docs/brief.pdf").contentType(" ") // blank → falls back to storage type
.originalFilename("brief.pdf").build();
when(documentService.getDocumentById(id)).thenReturn(doc);
java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{1, 2, 3});
when(fileService.downloadFile("docs/brief.pdf"))
.thenReturn(new FileService.S3FileDownload(
new org.springframework.core.io.InputStreamResource(stream), "application/pdf"));
mockMvc.perform(get("/api/documents/" + id + "/file"))
.andExpect(status().isOk());
}
@Test
@WithMockUser
void getDocumentFile_returns404_whenStorageFileNotFound() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Brief")
.filePath("docs/missing.pdf").contentType("application/pdf")
.originalFilename("missing.pdf").build();
when(documentService.getDocumentById(id)).thenReturn(doc);
when(fileService.downloadFile("docs/missing.pdf"))
.thenThrow(new FileService.StorageFileNotFoundException("not found"));
mockMvc.perform(get("/api/documents/" + id + "/file"))
.andExpect(status().isNotFound());
}
// ─── POST /api/documents/quick-upload — null/empty files ─────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
mockMvc.perform(multipart("/api/documents/quick-upload"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated").isEmpty())
.andExpect(jsonPath("$.errors").isEmpty());
}
// ─── GET /api/documents/incomplete-count ─────────────────────────────────
@Test
void getIncompleteCount_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/incomplete-count"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getIncompleteCount_returns200_withCount() throws Exception {
when(documentService.getIncompleteCount()).thenReturn(3L);
mockMvc.perform(get("/api/documents/incomplete-count"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(3));
}
// ─── GET /api/documents/incomplete ───────────────────────────────────────
@Test
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getIncomplete_returns200_withDTOList() throws Exception {
UUID id = UUID.randomUUID();
IncompleteDocumentDTO dto = new IncompleteDocumentDTO(id, "Unvollständig");
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"));
}
@Test
@WithMockUser
void getIncomplete_withSizeParam_passesItToService() throws Exception {
when(documentService.findIncompleteDocuments(5)).thenReturn(List.of());
mockMvc.perform(get("/api/documents/incomplete").param("size", "5"))
.andExpect(status().isOk());
verify(documentService).findIncompleteDocuments(5);
}
@Test
@WithMockUser
void getIncomplete_usesDefaultSizeWhenNotSpecified() throws Exception {
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/incomplete"))
.andExpect(status().isOk());
verify(documentService).findIncompleteDocuments(10);
}
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
@Test
void getNextIncomplete_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/incomplete/next")
.param("excludeId", UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getNextIncomplete_returns200_whenNextExists() throws Exception {
UUID excludeId = UUID.randomUUID();
Document next = Document.builder()
.id(UUID.randomUUID()).title("Nächster").originalFilename("next.pdf").build();
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.of(next));
mockMvc.perform(get("/api/documents/incomplete/next")
.param("excludeId", excludeId.toString()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Nächster"));
}
@Test
@WithMockUser
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
UUID excludeId = UUID.randomUUID();
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/documents/incomplete/next")
.param("excludeId", excludeId.toString()))
.andExpect(status().isNoContent());
}
// ─── GET /api/documents/recent-activity ──────────────────────────────────
@Test
void getRecentActivity_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/recent-activity"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getRecentActivity_returnsOkWithDocuments() throws Exception {
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
when(documentService.getRecentActivity(5)).thenReturn(List.of(doc1, doc2));
mockMvc.perform(get("/api/documents/recent-activity").param("size", "5"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].title").value("Alpha"))
.andExpect(jsonPath("$[1].title").value("Beta"));
}
@Test
@WithMockUser
void getRecentActivity_appliesDefaultSizeOfFive_whenSizeParamOmitted() throws Exception {
when(documentService.getRecentActivity(5)).thenReturn(List.of());
mockMvc.perform(get("/api/documents/recent-activity"))
.andExpect(status().isOk());
verify(documentService).getRecentActivity(5);
}
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
@Test

View File

@@ -1,343 +0,0 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.dto.NotificationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.NotificationType;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.NotificationService;
import org.raddatz.familienarchiv.service.SseEmitterRegistry;
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.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(NotificationController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class NotificationControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean NotificationService notificationService;
@MockitoBean UserService userService;
@MockitoBean SseEmitterRegistry sseEmitterRegistry;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final UUID USER_ID = UUID.randomUUID();
// ─── GET /api/notifications ───────────────────────────────────────────────
@Test
void getNotifications_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/notifications"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser")
void getNotifications_returns200_whenAuthenticatedWithNoPermissions() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
.thenReturn(new PageImpl<>(List.of()));
mockMvc.perform(get("/api/notifications"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNotifications_returns200WithList_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
NotificationDTO dto = new NotificationDTO(
UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(),
UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith", "Testdokument");
when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
.thenReturn(new PageImpl<>(List.of(dto), PageRequest.of(0, 10), 1));
mockMvc.perform(get("/api/notifications"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
.thenReturn(new PageImpl<>(List.of()));
mockMvc.perform(get("/api/notifications"))
.andExpect(status().isOk());
verify(notificationService).getNotifications(eq(USER_ID), any(), any(), any());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNotifications_withTypeAndReadFalse_passesFiltersToService() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), eq(NotificationType.MENTION), eq(false), any()))
.thenReturn(new PageImpl<>(List.of()));
mockMvc.perform(get("/api/notifications")
.param("type", "MENTION")
.param("read", "false"))
.andExpect(status().isOk());
verify(notificationService).getNotifications(eq(USER_ID), eq(NotificationType.MENTION), eq(false), any());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNotifications_withInvalidType_returns400() throws Exception {
mockMvc.perform(get("/api/notifications").param("type", "INVALID_TYPE"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNotifications_returns400_whenSizeExceedsMaximum() throws Exception {
mockMvc.perform(get("/api/notifications").param("size", "200"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getNotifications_returns400_whenSizeIsZero() throws Exception {
mockMvc.perform(get("/api/notifications").param("size", "0"))
.andExpect(status().isBadRequest());
}
// ─── POST /api/notifications/read-all ────────────────────────────────────
@Test
void markAllRead_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/notifications/read-all"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void markAllRead_returns204_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
mockMvc.perform(post("/api/notifications/read-all"))
.andExpect(status().isNoContent());
verify(notificationService).markAllRead(USER_ID);
}
// ─── PATCH /api/notifications/{id}/read ──────────────────────────────────
@Test
void markOneRead_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void markOneRead_returns403_whenNotificationBelongsToDifferentUser() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
UUID notifId = UUID.randomUUID();
when(userService.findByUsername("testuser")).thenReturn(user);
org.mockito.Mockito.doThrow(
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
.when(notificationService).markRead(notifId, USER_ID);
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
.andExpect(status().isForbidden());
}
// ─── GET /api/users/me/notification-preferences ──────────────────────────
@Test
void getPreferences_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/users/me/notification-preferences"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser")
void getPreferences_returns403_whenUserHasNoPermission() throws Exception {
mockMvc.perform(get("/api/users/me/notification-preferences"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void getPreferences_returns200_whenUserHasReadAll() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(true).notifyOnMention(false).build();
when(userService.findByUsername("testuser")).thenReturn(user);
mockMvc.perform(get("/api/users/me/notification-preferences"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.notifyOnReply").value(true))
.andExpect(jsonPath("$.notifyOnMention").value(false));
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void getPreferences_returns200_whenUserHasWriteAll() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(false).notifyOnMention(true).build();
when(userService.findByUsername("testuser")).thenReturn(user);
mockMvc.perform(get("/api/users/me/notification-preferences"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.notifyOnMention").value(true));
}
@Test
@WithMockUser(username = "testuser", authorities = {"ANNOTATE_ALL"})
void getPreferences_returns200_whenUserHasAnnotateAll() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(false).notifyOnMention(false).build();
when(userService.findByUsername("testuser")).thenReturn(user);
mockMvc.perform(get("/api/users/me/notification-preferences"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void getNotifications_returns200_whenUserHasOnlyWriteAll() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
.thenReturn(new PageImpl<>(List.of()));
mockMvc.perform(get("/api/notifications"))
.andExpect(status().isOk());
}
// ─── PUT /api/users/me/notification-preferences ──────────────────────────
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void updatePreferences_persistsBothBooleans() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(false).notifyOnMention(false).build();
when(userService.findByUsername("testuser")).thenReturn(user);
AppUser updated = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(true).notifyOnMention(true).build();
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
mockMvc.perform(put("/api/users/me/notification-preferences")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.notifyOnReply").value(true))
.andExpect(jsonPath("$.notifyOnMention").value(true));
}
@Test
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
void updatePreferences_returns200_whenUserHasWriteAll() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(false).notifyOnMention(false).build();
when(userService.findByUsername("testuser")).thenReturn(user);
AppUser updated = AppUser.builder().id(USER_ID).username("testuser")
.notifyOnReply(true).notifyOnMention(false).build();
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
mockMvc.perform(put("/api/users/me/notification-preferences")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.notifyOnReply").value(true));
}
// ─── GET /api/notifications/unread-count ─────────────────────────────────
@Test
void countUnread_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/notifications/unread-count"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void countUnread_returns200WithCount_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(notificationService.countUnread(USER_ID)).thenReturn(3L);
mockMvc.perform(get("/api/notifications/unread-count"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(3));
}
// ─── PATCH /api/notifications/{id}/read — additional cases ───────────────
// ─── GET /api/notifications/stream ───────────────────────────────────────
@Test
void stream_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/notifications/stream")
.accept(TEXT_EVENT_STREAM_VALUE))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void stream_returns200_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
when(userService.findByUsername("testuser")).thenReturn(user);
when(sseEmitterRegistry.register(USER_ID)).thenReturn(new org.springframework.web.servlet.mvc.method.annotation.SseEmitter());
mockMvc.perform(get("/api/notifications/stream")
.accept(TEXT_EVENT_STREAM_VALUE))
.andExpect(status().isOk());
}
// ─── PATCH /api/notifications/{id}/read — additional cases ───────────────
@Test
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
void markOneRead_returns404_whenNotificationDoesNotExist() throws Exception {
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
UUID notifId = UUID.randomUUID();
when(userService.findByUsername("testuser")).thenReturn(user);
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
.when(notificationService).markRead(notifId, USER_ID);
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
.andExpect(status().isNotFound());
}
}

View File

@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
@@ -12,22 +11,15 @@ import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(PersonController.class)
@@ -40,114 +32,6 @@ class PersonControllerTest {
@MockitoBean DocumentService documentService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/persons ─────────────────────────────────────────────────────
@Test
void getPersons_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/persons"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getPersons_returns200_withEmptyList() throws Exception {
when(personService.findAll(null)).thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/persons"))
.andExpect(status().isOk());
}
@Test
@WithMockUser
void getPersons_delegatesQueryParam_toService() throws Exception {
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
when(personService.findAll("Hans")).thenReturn(List.of(dto));
mockMvc.perform(get("/api/persons").param("q", "Hans"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Hans"));
}
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
return new PersonSummaryDTO() {
public java.util.UUID getId() { return UUID.randomUUID(); }
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public String getAlias() { return null; }
public Integer getBirthYear() { return null; }
public Integer getDeathYear() { return null; }
public String getNotes() { return null; }
public long getDocumentCount() { return 0; }
};
}
// ─── GET /api/persons/{id} ────────────────────────────────────────────────
@Test
void getPerson_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getPerson_returns200_whenFound() throws Exception {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
when(personService.getById(id)).thenReturn(person);
mockMvc.perform(get("/api/persons/{id}", id))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Anna"));
}
// ─── GET /api/persons/{id}/correspondents ─────────────────────────────────
@Test
void getCorrespondents_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/persons/{id}/correspondents", UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getCorrespondents_returns200_withoutFilter() throws Exception {
UUID personId = UUID.randomUUID();
when(personService.findCorrespondents(personId, null)).thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/persons/{id}/correspondents", personId))
.andExpect(status().isOk());
}
@Test
@WithMockUser
void getCorrespondents_returns200_withFilter() throws Exception {
UUID personId = UUID.randomUUID();
Person correspondent = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Gruyter").build();
when(personService.findCorrespondents(personId, "Walter")).thenReturn(List.of(correspondent));
mockMvc.perform(get("/api/persons/{id}/correspondents", personId).param("q", "Walter"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Walter"));
}
// ─── GET /api/persons/{id}/documents ──────────────────────────────────────
@Test
void getPersonDocuments_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/persons/{id}/documents", UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getPersonDocuments_returns200_whenAuthenticated() throws Exception {
UUID personId = UUID.randomUUID();
when(documentService.getDocumentsBySender(personId)).thenReturn(Collections.emptyList());
mockMvc.perform(get("/api/persons/{id}/documents", personId))
.andExpect(status().isOk());
}
// ─── GET /api/persons/{id}/received-documents ─────────────────────────────
@Test
@@ -165,232 +49,4 @@ class PersonControllerTest {
mockMvc.perform(get("/api/persons/{id}/received-documents", personId))
.andExpect(status().isOk());
}
// ─── POST /api/persons ────────────────────────────────────────────────────
@Test
void createPerson_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"lastName\":\"Müller\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns200_whenValid() throws Exception {
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Hans"));
}
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
@Test
void updatePerson_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns200_whenValid() throws Exception {
UUID id = UUID.randomUUID();
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
when(personService.updatePerson(eq(id), any())).thenReturn(updated);
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.lastName").value("Müller"));
}
// ─── POST /api/persons/{id}/merge ─────────────────────────────────────────
@Test
void mergePerson_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\" \"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void mergePerson_returns204_whenValid() throws Exception {
UUID sourceId = UUID.randomUUID();
UUID targetId = UUID.randomUUID();
mockMvc.perform(post("/api/persons/{id}/merge", sourceId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
.andExpect(status().isNoContent());
}
// ─── PUT /api/persons/{id} — lastName blank branch ────────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
// firstName valid, lastName blank → second || operand = true → 400
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
.andExpect(status().isBadRequest());
}
// ─── Phase 2.2: POST /api/persons with full PersonUpdateDTO ───────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void createPerson_returns200_withAllSixFields() throws Exception {
UUID id = UUID.randomUUID();
Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz")
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
"\"notes\":\"Some notes\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Maria"))
.andExpect(jsonPath("$.alias").value("Oma Maria"))
.andExpect(jsonPath("$.birthYear").value(1901));
}
// ─── Phase 1.2: @Size constraints ─────────────────────────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
String oversizedNotes = "x".repeat(5001);
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
String oversizedFirstName = "x".repeat(101);
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
.andExpect(status().isBadRequest());
}
// ─── Phase 1.1: @RequirePermission(WRITE_ALL) on write endpoints ──────────
@Test
@WithMockUser(authorities = "READ_ALL")
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(post("/api/persons")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isForbidden());
}
}

View File

@@ -1,61 +0,0 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
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 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(StatsController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class StatsControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean PersonRepository personRepository;
@MockitoBean DocumentRepository documentRepository;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@Test
void getStats_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/stats"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getStats_returns200_withCorrectCounts() throws Exception {
when(personRepository.count()).thenReturn(4L);
when(documentRepository.count()).thenReturn(12L);
mockMvc.perform(get("/api/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalPersons").value(4))
.andExpect(jsonPath("$.totalDocuments").value(12));
}
@Test
@WithMockUser
void getStats_returns200_withZeroCounts() throws Exception {
when(personRepository.count()).thenReturn(0L);
when(documentRepository.count()).thenReturn(0L);
mockMvc.perform(get("/api/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalPersons").value(0))
.andExpect(jsonPath("$.totalDocuments").value(0));
}
}

View File

@@ -1,78 +0,0 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
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.UUID;
import static org.mockito.ArgumentMatchers.any;
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(UserController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class UserControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/users/me ────────────────────────────────────────────────────
@Test
void getCurrentUser_returns401_whenUnauthenticated() throws Exception {
// authentication == null → returns 401 (covers null/!isAuthenticated branch)
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "anna")
void getCurrentUser_returns200_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
when(userService.findByUsername("anna")).thenReturn(user);
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("anna"));
}
// ─── GET /api/users/{id} ──────────────────────────────────────────────────
@Test
@WithMockUser(username = "reader")
void getUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
UUID id = UUID.randomUUID();
AppUser target = AppUser.builder().id(id).username("target").build();
when(userService.getById(id)).thenReturn(target);
mockMvc.perform(get("/api/users/" + id))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(username = "admin", authorities = {"ADMIN_USER"})
void getUser_returns200_whenCallerHasAdminUserPermission() throws Exception {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("target").build();
when(userService.getById(id)).thenReturn(user);
mockMvc.perform(get("/api/users/" + id))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("target"));
}
}

View File

@@ -1,94 +0,0 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
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.UserSearchService;
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.UUID;
import java.util.stream.IntStream;
import static org.hamcrest.Matchers.lessThanOrEqualTo;
import static org.mockito.ArgumentMatchers.anyString;
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(UserSearchController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class UserSearchControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean UserSearchService userSearchService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@Test
void search_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void search_returns403_whenUserLacksPermission() throws Exception {
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = {"ANNOTATE_ALL"})
void search_returns200_whenUserHasAnnotateAll() throws Exception {
when(userSearchService.search("Hans")).thenReturn(List.of());
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = {"READ_ALL"})
void search_returns200_whenAuthenticated() throws Exception {
AppUser user = AppUser.builder().id(UUID.randomUUID())
.firstName("Hans").lastName("Mueller").username("hans").build();
when(userSearchService.search("Hans")).thenReturn(List.of(user));
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Hans"));
}
@Test
@WithMockUser(authorities = {"READ_ALL"})
void search_returnsEmptyList_whenQueryIsEmpty() throws Exception {
when(userSearchService.search("")).thenReturn(List.of());
mockMvc.perform(get("/api/users/search").param("q", ""))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser(authorities = {"READ_ALL"})
void search_returnsAtMostTenResults() throws Exception {
List<AppUser> elevenUsers = IntStream.range(0, 11)
.mapToObj(i -> AppUser.builder().id(UUID.randomUUID())
.firstName("User").lastName(String.valueOf(i)).username("u" + i).build())
.toList();
when(userSearchService.search(anyString())).thenReturn(elevenUsers.subList(0, 10));
mockMvc.perform(get("/api/users/search").param("q", "a"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(lessThanOrEqualTo(10)));
}
}

View File

@@ -1,256 +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.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class DocumentRepositoryTest {
@Autowired
private DocumentRepository documentRepository;
@Autowired
private PersonRepository personRepository;
// ─── save and findById ────────────────────────────────────────────────────
@Test
void save_persistsDocument_andFindByIdReturnsSameDocument() {
Document document = Document.builder()
.title("Testbrief")
.originalFilename("testbrief.pdf")
.status(DocumentStatus.PLACEHOLDER)
.build();
Document saved = documentRepository.save(document);
Optional<Document> found = documentRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getTitle()).isEqualTo("Testbrief");
assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
}
// ─── findByStatus ─────────────────────────────────────────────────────────
@Test
void findByStatus_returnsOnlyDocumentsWithMatchingStatus() {
documentRepository.save(Document.builder()
.title("Placeholder Doc")
.originalFilename("placeholder.pdf")
.status(DocumentStatus.PLACEHOLDER)
.build());
documentRepository.save(Document.builder()
.title("Uploaded Doc")
.originalFilename("uploaded.pdf")
.status(DocumentStatus.UPLOADED)
.build());
List<Document> placeholders = documentRepository.findByStatus(DocumentStatus.PLACEHOLDER);
assertThat(placeholders).extracting(Document::getStatus)
.containsOnly(DocumentStatus.PLACEHOLDER);
}
// ─── findByOriginalFilename ───────────────────────────────────────────────
@Test
void findByOriginalFilename_returnsDocument_whenFilenameMatches() {
documentRepository.save(Document.builder()
.title("Omas Brief")
.originalFilename("omas_brief.pdf")
.status(DocumentStatus.PLACEHOLDER)
.build());
Optional<Document> found = documentRepository.findByOriginalFilename("omas_brief.pdf");
assertThat(found).isPresent();
assertThat(found.get().getTitle()).isEqualTo("Omas Brief");
}
@Test
void findByOriginalFilename_returnsEmpty_whenFilenameDoesNotExist() {
Optional<Document> found = documentRepository.findByOriginalFilename("does_not_exist.pdf");
assertThat(found).isEmpty();
}
// ─── existsByOriginalFilename ─────────────────────────────────────────────
@Test
void existsByOriginalFilename_returnsTrue_whenDocumentExists() {
documentRepository.save(Document.builder()
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.PLACEHOLDER)
.build());
assertThat(documentRepository.existsByOriginalFilename("brief.pdf")).isTrue();
}
@Test
void existsByOriginalFilename_returnsFalse_whenDocumentDoesNotExist() {
assertThat(documentRepository.existsByOriginalFilename("nonexistent.pdf")).isFalse();
}
// ─── findBySenderId ───────────────────────────────────────────────────────
@Test
void findBySenderId_returnsDocuments_whereSenderIdMatches() {
Person sender = personRepository.save(Person.builder()
.firstName("Hans")
.lastName("Müller")
.build());
documentRepository.save(Document.builder()
.title("Brief von Hans")
.originalFilename("brief_hans.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.build());
List<Document> docs = documentRepository.findBySenderId(sender.getId());
assertThat(docs).hasSize(1);
assertThat(docs.get(0).getSender().getId()).isEqualTo(sender.getId());
}
// ─── countByMetadataCompleteFalse ─────────────────────────────────────────
@Test
void countByMetadataCompleteFalse_returnsNumberOfIncompleteDocuments() {
documentRepository.save(Document.builder()
.title("Incomplete")
.originalFilename("incomplete.pdf")
.status(DocumentStatus.PLACEHOLDER)
.metadataComplete(false)
.build());
documentRepository.save(Document.builder()
.title("Complete")
.originalFilename("complete.pdf")
.status(DocumentStatus.UPLOADED)
.metadataComplete(true)
.build());
assertThat(documentRepository.countByMetadataCompleteFalse()).isEqualTo(1);
}
// ─── findAll (PageRequest) — recent activity ──────────────────────────────
@Test
void findAll_withPageRequest_returnsOnlySizeRows_notFullTable() {
for (int i = 0; i < 10; i++) {
documentRepository.save(Document.builder()
.title("Doc " + i).originalFilename("doc" + i + ".pdf")
.status(DocumentStatus.PLACEHOLDER).build());
}
Page<Document> result = documentRepository.findAll(
PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "updatedAt")));
assertThat(result.getContent()).hasSize(3);
assertThat(result.getTotalElements()).isEqualTo(10);
}
// ─── findByMetadataCompleteFalse (Pageable) ───────────────────────────────
@Test
void findByMetadataCompleteFalse_withPageable_returnsOnlyIncompleteAndRespectsSizeCap() {
for (int i = 0; i < 5; i++) {
documentRepository.save(Document.builder()
.title("Incomplete " + i).originalFilename("inc" + i + ".pdf")
.status(DocumentStatus.UPLOADED).metadataComplete(false).build());
}
documentRepository.save(Document.builder()
.title("Complete").originalFilename("complete.pdf")
.status(DocumentStatus.REVIEWED).metadataComplete(true).build());
Page<Document> result = documentRepository.findByMetadataCompleteFalse(
PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdAt")));
assertThat(result.getContent()).hasSize(3);
assertThat(result.getTotalElements()).isEqualTo(5);
assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
}
// ─── findSinglePersonCorrespondence — DISTINCT / multi-receiver safety ────
@Test
void findSinglePersonCorrespondence_returnsExactlyOneResult_whenDocumentHasThreeReceiversAndOneMatchesPersonId() {
Person sender = personRepository.save(Person.builder()
.firstName("Hans").lastName("Müller").build());
Person receiver1 = personRepository.save(Person.builder()
.firstName("Anna").lastName("Schmidt").build());
Person receiver2 = personRepository.save(Person.builder()
.firstName("Bertha").lastName("Wagner").build());
Person receiver3 = personRepository.save(Person.builder()
.firstName("Clara").lastName("Koch").build());
// Document addressed to all three receivers
Document doc = documentRepository.save(Document.builder()
.title("Rundschreiben")
.originalFilename("rundschreiben.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.receivers(new HashSet<>(Set.of(receiver1, receiver2, receiver3)))
.documentDate(LocalDate.of(1950, 6, 1))
.build());
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
LocalDate from = LocalDate.of(1900, 1, 1);
LocalDate to = LocalDate.of(2000, 1, 1);
// Query for receiver1 — the DISTINCT must collapse the 3 JOIN rows into 1 result
List<Document> results = documentRepository.findSinglePersonCorrespondence(
receiver1.getId(), from, to, sort);
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(doc.getId());
}
@Test
void findSinglePersonCorrespondence_includesDocumentsWherePerson_isSender() {
Person sender = personRepository.save(Person.builder()
.firstName("Hans").lastName("Müller").build());
Person receiver = personRepository.save(Person.builder()
.firstName("Anna").lastName("Schmidt").build());
documentRepository.save(Document.builder()
.title("Brief als Absender")
.originalFilename("brief_absender.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.receivers(new HashSet<>(Set.of(receiver)))
.documentDate(LocalDate.of(1950, 6, 1))
.build());
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
LocalDate from = LocalDate.of(1900, 1, 1);
LocalDate to = LocalDate.of(2000, 1, 1);
List<Document> results = documentRepository.findSinglePersonCorrespondence(
sender.getId(), from, to, sort);
assertThat(results).hasSize(1);
}
}

View File

@@ -1,272 +0,0 @@
package org.raddatz.familienarchiv.repository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.domain.Specification;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class DocumentSpecificationsTest {
@Autowired DocumentRepository documentRepository;
@Autowired PersonRepository personRepository;
@Autowired TagRepository tagRepository;
private Person sender;
private Person receiver;
private Document briefEarly;
private Document briefLate;
private Document photoDoc;
@BeforeEach
void setUp() {
documentRepository.deleteAll();
personRepository.deleteAll();
tagRepository.deleteAll();
sender = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
receiver = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
Tag tagFamilie = tagRepository.save(Tag.builder().name("Familie").build());
Tag tagUrlaub = tagRepository.save(Tag.builder().name("Urlaub").build());
briefEarly = documentRepository.save(Document.builder()
.title("Alter Brief")
.originalFilename("brief_early.pdf")
.status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1940, 5, 1))
.transcription("Liebe Anna, ich schreibe dir aus dem Krieg")
.location("Berlin")
.sender(sender)
.receivers(Set.of(receiver))
.tags(Set.of(tagFamilie))
.build());
briefLate = documentRepository.save(Document.builder()
.title("Neuerer Brief")
.originalFilename("brief_late.pdf")
.status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1960, 8, 15))
.sender(sender)
.tags(Set.of(tagUrlaub))
.build());
photoDoc = documentRepository.save(Document.builder()
.title("Familienfoto")
.originalFilename("familienfoto.jpg")
.status(DocumentStatus.PLACEHOLDER)
.build());
}
// ─── hasText ──────────────────────────────────────────────────────────────
@Test
void hasText_returnsAllDocuments_whenTextIsNull() {
List<Document> result = documentRepository.findAll(Specification.where(hasText(null)));
assertThat(result).hasSize(3);
}
@Test
void hasText_returnsAllDocuments_whenTextIsBlank() {
List<Document> result = documentRepository.findAll(Specification.where(hasText(" ")));
assertThat(result).hasSize(3);
}
@Test
void hasText_filtersOnTitle() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("familienfoto")));
assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto");
}
@Test
void hasText_filtersOnOriginalFilename() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("brief_late")));
assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief");
}
@Test
void hasText_filtersOnTranscription() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("schreibe dir")));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasText_filtersOnLocation() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("berlin")));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasText_isCaseInsensitive() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("BRIEF")));
assertThat(result).extracting(Document::getTitle).containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief");
}
@Test
void hasText_returnsEmpty_whenNoMatch() {
List<Document> result = documentRepository.findAll(Specification.where(hasText("xyznotexist")));
assertThat(result).isEmpty();
}
// ─── hasSender ────────────────────────────────────────────────────────────
@Test
void hasSender_returnsAllDocuments_whenPersonIdIsNull() {
List<Document> result = documentRepository.findAll(Specification.where(hasSender(null)));
assertThat(result).hasSize(3);
}
@Test
void hasSender_filtersDocumentsBySender() {
List<Document> result = documentRepository.findAll(Specification.where(hasSender(sender.getId())));
assertThat(result).extracting(Document::getTitle)
.containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief");
}
@Test
void hasSender_returnsEmpty_whenSenderHasNoDocuments() {
List<Document> result = documentRepository.findAll(Specification.where(hasSender(receiver.getId())));
assertThat(result).isEmpty();
}
// ─── hasReceiver ──────────────────────────────────────────────────────────
@Test
void hasReceiver_returnsAllDocuments_whenPersonIdIsNull() {
List<Document> result = documentRepository.findAll(Specification.where(hasReceiver(null)));
assertThat(result).hasSize(3);
}
@Test
void hasReceiver_filtersDocumentsByReceiver() {
List<Document> result = documentRepository.findAll(Specification.where(hasReceiver(receiver.getId())));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasReceiver_returnsEmpty_whenReceiverHasNoDocuments() {
List<Document> result = documentRepository.findAll(Specification.where(hasReceiver(sender.getId())));
assertThat(result).isEmpty();
}
// ─── isBetween ────────────────────────────────────────────────────────────
@Test
void isBetween_returnsAllDocuments_whenBothDatesAreNull() {
List<Document> result = documentRepository.findAll(Specification.where(isBetween(null, null)));
assertThat(result).hasSize(3);
}
@Test
void isBetween_filtersByBothDates() {
List<Document> result = documentRepository.findAll(
Specification.where(isBetween(LocalDate.of(1939, 1, 1), LocalDate.of(1945, 12, 31))));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void isBetween_filtersByStartDateOnly() {
List<Document> result = documentRepository.findAll(
Specification.where(isBetween(LocalDate.of(1950, 1, 1), null)));
assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief");
}
@Test
void isBetween_filtersByEndDateOnly() {
List<Document> result = documentRepository.findAll(
Specification.where(isBetween(null, LocalDate.of(1945, 12, 31))));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void isBetween_returnsEmpty_whenNoDatesInRange() {
List<Document> result = documentRepository.findAll(
Specification.where(isBetween(LocalDate.of(1970, 1, 1), LocalDate.of(1980, 12, 31))));
assertThat(result).isEmpty();
}
// ─── hasTags ──────────────────────────────────────────────────────────────
@Test
void hasTags_returnsAllDocuments_whenTagListIsNull() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(null)));
assertThat(result).hasSize(3);
}
@Test
void hasTags_returnsAllDocuments_whenTagListIsEmpty() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of())));
assertThat(result).hasSize(3);
}
@Test
void hasTags_filtersDocumentsByTag() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Familie"))));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasTags_isCaseInsensitive() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("familie"))));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasTags_requiresAllTagsToBePresent_andLogic() {
// briefEarly has "Familie" but not "Urlaub" — should be excluded
List<Document> result = documentRepository.findAll(
Specification.where(hasTags(List.of("Familie", "Urlaub"))));
assertThat(result).isEmpty();
}
@Test
void hasTags_skipsEmptyTagNames() {
// An empty string in the tag list should be ignored
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of(" ", "Familie"))));
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
}
@Test
void hasTags_returnsEmpty_whenTagDoesNotExist() {
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Unbekannt"))));
assertThat(result).isEmpty();
}
// ─── hasStatus ────────────────────────────────────────────────────────────
@Test
void hasStatus_returnsAllDocuments_whenStatusIsNull() {
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(null)));
assertThat(result).hasSize(3);
}
@Test
void hasStatus_returnsOnlyMatchingDocuments_whenStatusIsSet() {
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.PLACEHOLDER)));
assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto");
}
@Test
void hasStatus_returnsEmpty_whenNoDocumentMatchesStatus() {
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.REVIEWED)));
assertThat(result).isEmpty();
}
}

View File

@@ -1,119 +0,0 @@
package org.raddatz.familienarchiv.repository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Notification;
import org.raddatz.familienarchiv.model.NotificationType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class NotificationRepositoryTest {
@Autowired NotificationRepository notificationRepository;
@Autowired AppUserRepository appUserRepository;
private AppUser userA;
private AppUser userB;
@BeforeEach
void setUp() {
notificationRepository.deleteAll();
appUserRepository.deleteAll();
userA = appUserRepository.save(AppUser.builder().username("userA").password("pw").build());
userB = appUserRepository.save(AppUser.builder().username("userB").password("pw").build());
}
// ─── findByRecipientIdAndTypeAndReadFalse ─────────────────────────────────
@Test
void returnsOnlyUnreadMentions_forTargetUser() {
notificationRepository.save(mention(userA, false)); // ✓ match
notificationRepository.save(mention(userA, true)); // read — excluded
notificationRepository.save(reply(userA, false)); // REPLY — excluded
notificationRepository.save(mention(userB, false)); // different user — excluded
Page<Notification> result = notificationRepository
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
userA.getId(), NotificationType.MENTION, Pageable.ofSize(10));
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).getRecipient().getId()).isEqualTo(userA.getId());
assertThat(result.getContent().get(0).getType()).isEqualTo(NotificationType.MENTION);
assertThat(result.getContent().get(0).isRead()).isFalse();
}
@Test
void returnsEmpty_whenAllMentionsAreRead() {
notificationRepository.save(mention(userA, true));
Page<Notification> result = notificationRepository
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
userA.getId(), NotificationType.MENTION, Pageable.ofSize(10));
assertThat(result.getContent()).isEmpty();
}
@Test
void respectsSizeLimit() {
for (int i = 0; i < 5; i++) {
notificationRepository.save(mention(userA, false));
}
Page<Notification> result = notificationRepository
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
userA.getId(), NotificationType.MENTION, Pageable.ofSize(3));
assertThat(result.getContent()).hasSize(3);
assertThat(result.getTotalElements()).isEqualTo(5);
}
// ─── findByRecipientIdAndType (without read filter) ──────────────────────
@Test
void findByType_returnsBothReadAndUnreadMentions() {
notificationRepository.save(mention(userA, false)); // unread
notificationRepository.save(mention(userA, true)); // read — should also be included
notificationRepository.save(reply(userA, false)); // REPLY — excluded
notificationRepository.save(mention(userB, false)); // different user — excluded
Page<Notification> result = notificationRepository
.findByRecipientIdAndTypeOrderByCreatedAtDesc(
userA.getId(), NotificationType.MENTION, Pageable.ofSize(10));
assertThat(result.getContent()).hasSize(2);
assertThat(result.getContent()).allMatch(n -> n.getType() == NotificationType.MENTION);
assertThat(result.getContent()).allMatch(n -> n.getRecipient().getId().equals(userA.getId()));
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Notification mention(AppUser recipient, boolean read) {
return Notification.builder()
.recipient(recipient)
.type(NotificationType.MENTION)
.actorName("Tester")
.read(read)
.build();
}
private Notification reply(AppUser recipient, boolean read) {
return Notification.builder()
.recipient(recipient)
.type(NotificationType.REPLY)
.actorName("Tester")
.read(read)
.build();
}
}

View File

@@ -1,386 +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.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class PersonRepositoryTest {
@Autowired
private PersonRepository personRepository;
@Autowired
private DocumentRepository documentRepository;
@PersistenceContext
private EntityManager entityManager;
// ─── save and findById ────────────────────────────────────────────────────
@Test
void save_persistsPerson_andFindByIdReturnsSamePerson() {
Person person = Person.builder()
.firstName("Anna")
.lastName("Schmidt")
.build();
Person saved = personRepository.save(person);
Optional<Person> found = personRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getFirstName()).isEqualTo("Anna");
assertThat(found.get().getLastName()).isEqualTo("Schmidt");
}
// ─── searchByName ─────────────────────────────────────────────────────────
@Test
void searchByName_findsByFirstName() {
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
List<Person> results = personRepository.searchByName("Hans");
assertThat(results).hasSize(1);
assertThat(results.get(0).getFirstName()).isEqualTo("Hans");
}
@Test
void searchByName_findsByLastName() {
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
List<Person> results = personRepository.searchByName("Schmidt");
assertThat(results).hasSize(1);
assertThat(results.get(0).getLastName()).isEqualTo("Schmidt");
}
@Test
void searchByName_isCaseInsensitive() {
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
List<Person> results = personRepository.searchByName("hans");
assertThat(results).hasSize(1);
}
@Test
void searchByName_findsByAlias() {
personRepository.save(Person.builder()
.firstName("Hans").lastName("Müller").alias("Opa Hans").build());
List<Person> results = personRepository.searchByName("Opa Hans");
assertThat(results).hasSize(1);
}
// ─── findAllByOrderByLastNameAscFirstNameAsc ──────────────────────────────
@Test
void findAllByOrderByLastNameAscFirstNameAsc_returnsSortedByLastNameThenFirstName() {
personRepository.save(Person.builder().firstName("Bernd").lastName("Ziegler").build());
personRepository.save(Person.builder().firstName("Anna").lastName("Müller").build());
personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
List<Person> sorted = personRepository.findAllByOrderByLastNameAscFirstNameAsc();
assertThat(sorted).extracting(Person::getLastName)
.startsWith("Müller", "Müller");
assertThat(sorted.stream()
.filter(p -> p.getLastName().equals("Müller"))
.map(Person::getFirstName)
.toList())
.containsExactly("Anna", "Clara");
}
// ─── findByAliasIgnoreCase ────────────────────────────────────────────────
@Test
void findByAliasIgnoreCase_returnsMatchingPerson() {
personRepository.save(Person.builder()
.firstName("Karl").lastName("Brandt").alias("Opa Karl").build());
Optional<Person> found = personRepository.findByAliasIgnoreCase("opa karl");
assertThat(found).isPresent();
assertThat(found.get().getFirstName()).isEqualTo("Karl");
}
@Test
void findByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
Optional<Person> found = personRepository.findByAliasIgnoreCase("nobody");
assertThat(found).isEmpty();
}
// ─── findByFirstNameIgnoreCaseAndLastNameIgnoreCase ───────────────────────
@Test
void findByFirstNameIgnoreCaseAndLastNameIgnoreCase_returnsMatch() {
personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());
Optional<Person> found = personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(
"maria", "raddatz");
assertThat(found).isPresent();
assertThat(found.get().getFirstName()).isEqualTo("Maria");
}
// ─── findCorrespondents ───────────────────────────────────────────────────
@Test
void findCorrespondents_returnsPersonsWhoSharedDocumentsWith() {
Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
// Walter sends to Anna (1 document)
documentRepository.save(Document.builder()
.title("Brief 1").originalFilename("brief1.pdf")
.status(DocumentStatus.UPLOADED)
.sender(walter).receivers(Set.of(anna)).build());
// Walter sends to Clara (2 documents — Clara should rank higher)
documentRepository.save(Document.builder()
.title("Brief 2").originalFilename("brief2.pdf")
.status(DocumentStatus.UPLOADED)
.sender(walter).receivers(Set.of(clara)).build());
documentRepository.save(Document.builder()
.title("Brief 3").originalFilename("brief3.pdf")
.status(DocumentStatus.UPLOADED)
.sender(walter).receivers(Set.of(clara)).build());
List<Person> correspondents = personRepository.findCorrespondents(walter.getId());
assertThat(correspondents).extracting(Person::getFirstName)
.containsExactly("Clara", "Anna"); // Clara ranks first (2 documents)
}
@Test
void findCorrespondents_returnsEmpty_whenPersonHasNoDocuments() {
Person solo = personRepository.save(Person.builder().firstName("Solo").lastName("Mensch").build());
List<Person> correspondents = personRepository.findCorrespondents(solo.getId());
assertThat(correspondents).isEmpty();
}
// ─── findCorrespondentsWithFilter ─────────────────────────────────────────
@Test
void findCorrespondentsWithFilter_returnsOnlyMatchingCorrespondents() {
Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
Person bernd = personRepository.save(Person.builder().firstName("Bernd").lastName("Braun").build());
documentRepository.save(Document.builder()
.title("Brief an Anna").originalFilename("anna.pdf")
.status(DocumentStatus.UPLOADED)
.sender(walter).receivers(Set.of(anna)).build());
documentRepository.save(Document.builder()
.title("Brief an Bernd").originalFilename("bernd.pdf")
.status(DocumentStatus.UPLOADED)
.sender(walter).receivers(Set.of(bernd)).build());
List<Person> filtered = personRepository.findCorrespondentsWithFilter(walter.getId(), "Anna");
assertThat(filtered).extracting(Person::getFirstName).containsExactly("Anna");
}
@Test
void findCorrespondentsWithFilter_isCaseInsensitive() {
Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(walter).receivers(Set.of(anna)).build());
List<Person> filtered = personRepository.findCorrespondentsWithFilter(walter.getId(), "schmidt");
assertThat(filtered).hasSize(1);
assertThat(filtered.get(0).getLastName()).isEqualTo("Schmidt");
}
// ─── reassignSender ───────────────────────────────────────────────────────
@Test
void reassignSender_updatesDocumentsSenderFromSourceToTarget() {
Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build());
Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build());
documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(source).build());
personRepository.reassignSender(source.getId(), target.getId());
entityManager.flush();
entityManager.clear();
List<Document> docs = documentRepository.findBySenderId(target.getId());
assertThat(docs).hasSize(1);
assertThat(documentRepository.findBySenderId(source.getId())).isEmpty();
}
// ─── insertMissingReceiverReference ──────────────────────────────────────
@Test
void insertMissingReceiverReference_addsTargetWhereSourceWasReceiver() {
Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build());
Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build());
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
Document doc = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender).receivers(Set.of(source)).build());
personRepository.insertMissingReceiverReference(source.getId(), target.getId());
entityManager.flush();
entityManager.clear();
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
assertThat(reloaded.getReceivers())
.extracting(Person::getId)
.contains(target.getId());
}
@Test
void insertMissingReceiverReference_doesNotCreateDuplicate_whenTargetAlreadyReceiver() {
Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build());
Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build());
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
// target is already a receiver together with source
Document doc = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender).receivers(Set.of(source, target)).build());
personRepository.insertMissingReceiverReference(source.getId(), target.getId());
entityManager.flush();
entityManager.clear();
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
long targetCount = reloaded.getReceivers().stream()
.filter(p -> p.getId().equals(target.getId())).count();
assertThat(targetCount).isEqualTo(1); // no duplicate
}
// ─── Phase 3.2: findAllWithDocumentCount ──────────────────────────────────
@Test
void findAllWithDocumentCount_includesDocumentCountAsSenderAndReceiver() {
Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
// Walter sends 2 docs to Anna (Anna receives 2)
documentRepository.save(Document.builder()
.title("Brief 1").originalFilename("b1.pdf")
.status(DocumentStatus.UPLOADED)
.sender(walter).receivers(Set.of(anna)).build());
documentRepository.save(Document.builder()
.title("Brief 2").originalFilename("b2.pdf")
.status(DocumentStatus.UPLOADED)
.sender(walter).receivers(Set.of(anna)).build());
// Anna also sends 1 doc to Walter
documentRepository.save(Document.builder()
.title("Brief 3").originalFilename("b3.pdf")
.status(DocumentStatus.UPLOADED)
.sender(anna).receivers(Set.of(walter)).build());
List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();
PersonSummaryDTO walterSummary = result.stream()
.filter(p -> p.getId().equals(walter.getId())).findFirst().orElseThrow();
PersonSummaryDTO annaSummary = result.stream()
.filter(p -> p.getId().equals(anna.getId())).findFirst().orElseThrow();
assertThat(walterSummary.getDocumentCount()).isEqualTo(3); // sent 2, received 1
assertThat(annaSummary.getDocumentCount()).isEqualTo(3); // sent 1, received 2
}
@Test
void findAllWithDocumentCount_returnsZero_whenPersonHasNoDocuments() {
Person solo = personRepository.save(Person.builder().firstName("Solo").lastName("Mensch").build());
List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();
PersonSummaryDTO soloSummary = result.stream()
.filter(p -> p.getId().equals(solo.getId())).findFirst().orElseThrow();
assertThat(soloSummary.getDocumentCount()).isEqualTo(0);
}
@Test
void searchWithDocumentCount_filtersAndIncludesCount() {
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.sender(hans).receivers(Set.of(anna)).build());
List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("Hans");
assertThat(result).hasSize(1);
assertThat(result.get(0).getFirstName()).isEqualTo("Hans");
assertThat(result.get(0).getDocumentCount()).isEqualTo(1);
}
@Test
void searchWithDocumentCount_isCaseInsensitive() {
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("hans");
assertThat(result).hasSize(1);
}
// ─── deleteReceiverReferences ─────────────────────────────────────────────
@Test
void deleteReceiverReferences_removesPersonFromAllDocumentReceivers() {
Person toDelete = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
Document doc1 = documentRepository.save(Document.builder()
.title("Brief 1").originalFilename("b1.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender).receivers(Set.of(toDelete)).build());
Document doc2 = documentRepository.save(Document.builder()
.title("Brief 2").originalFilename("b2.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender).receivers(Set.of(toDelete)).build());
personRepository.deleteReceiverReferences(toDelete.getId());
entityManager.flush();
entityManager.clear();
assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty();
}
}

View File

@@ -1,282 +0,0 @@
package org.raddatz.familienarchiv.service;
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.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@ExtendWith(MockitoExtension.class)
class AnnotationServiceTest {
@Mock AnnotationRepository annotationRepository;
@InjectMocks AnnotationService annotationService;
// ─── createAnnotation ─────────────────────────────────────────────────────
@Test
void createAnnotation_throwsConflict_whenAnnotationOverlapsExisting() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.3, 0.3, "#ff0000");
DocumentAnnotation existing = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.2).y(0.2).width(0.3).height(0.3).color("#00ff00").build();
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
.thenReturn(List.of(existing));
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
verify(annotationRepository, never()).save(any());
}
@Test
void createAnnotation_savesAndReturns_whenNoOverlap() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
when(annotationRepository.save(any())).thenReturn(saved);
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result).isEqualTo(saved);
verify(annotationRepository).save(any());
}
// ─── deleteAnnotation ─────────────────────────────────────────────────────
@Test
void deleteAnnotation_throwsNotFound_whenMissing() {
UUID docId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
when(annotationRepository.findByIdAndDocumentId(annotId, docId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, UUID.randomUUID()))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
}
@Test
void deleteAnnotation_throwsForbidden_whenNotOwner() {
UUID docId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID otherId = UUID.randomUUID();
DocumentAnnotation annotation = DocumentAnnotation.builder()
.id(annotId).documentId(docId).createdBy(ownerId).build();
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
.thenReturn(Optional.of(annotation));
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, otherId))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
verify(annotationRepository, never()).delete(any());
}
@Test
void deleteAnnotation_succeeds_whenOwner() {
UUID docId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
DocumentAnnotation annotation = DocumentAnnotation.builder()
.id(annotId).documentId(docId).createdBy(ownerId).build();
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
.thenReturn(Optional.of(annotation));
annotationService.deleteAnnotation(docId, annotId, ownerId);
verify(annotationRepository).delete(annotation);
}
@Test
void createAnnotation_setsFileHash_whenProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
String fileHash = "abc123";
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash);
assertThat(result.getFileHash()).isEqualTo(fileHash);
}
@Test
void createAnnotation_setsNullFileHash_whenNoneProvided() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
assertThat(result.getFileHash()).isNull();
}
// ─── listAnnotations ──────────────────────────────────────────────────────
@Test
void listAnnotations_returnsAllForDocument() {
UUID docId = UUID.randomUUID();
DocumentAnnotation a = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).build();
when(annotationRepository.findByDocumentId(docId)).thenReturn(List.of(a));
assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
}
// ─── backfillAnnotationFileHashForDocument ────────────────────────────────
@Test
void backfillAnnotationFileHashForDocument_setsHashOnAnnotationsWithNullHash() {
UUID docId = UUID.randomUUID();
String hash = "abc123";
DocumentAnnotation a = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).build();
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of(a));
annotationService.backfillAnnotationFileHashForDocument(docId, hash);
assertThat(a.getFileHash()).isEqualTo(hash);
verify(annotationRepository).save(a);
}
@Test
void backfillAnnotationFileHashForDocument_doesNothingWhenNoAnnotations() {
UUID docId = UUID.randomUUID();
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of());
annotationService.backfillAnnotationFileHashForDocument(docId, "hash");
verify(annotationRepository, never()).save(any());
}
// ─── deleteAnnotation — null userId ───────────────────────────────────────
@Test
void deleteAnnotation_throwsForbidden_whenUserIdIsNull() {
UUID docId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
DocumentAnnotation annotation = DocumentAnnotation.builder()
.id(annotId).documentId(docId).createdBy(ownerId).build();
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
.thenReturn(Optional.of(annotation));
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, null))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
}
// ─── overlaps — partial overlap cases ────────────────────────────────────
@Test
void createAnnotation_noConflict_whenAnnotationIsToTheLeft() {
// existing: x=0.5, w=0.3 (x2=0.8); dto: x=0.0, w=0.4 (dx2=0.4)
// existing.getX() < dx2 → 0.5 < 0.4 → FALSE → no overlap (first && fails)
UUID docId = UUID.randomUUID();
DocumentAnnotation existing = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.5).y(0.0).width(0.3).height(0.5).color("#ff0000").build();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.4, 0.5, "#0000ff");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
verify(annotationRepository).save(any());
}
@Test
void createAnnotation_noConflict_whenAnnotationIsToTheRight() {
// existing: x=0.0, w=0.1 (ex2=0.1); dto: x=0.2, w=0.3 (dx2=0.5)
// existing.getX() < dx2 → 0.0 < 0.5 → TRUE
// ex2 > dto.getX() → 0.1 > 0.2 → FALSE → no overlap (second && fails)
UUID docId = UUID.randomUUID();
DocumentAnnotation existing = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.0).y(0.0).width(0.1).height(0.5).color("#ff0000").build();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.2, 0.0, 0.3, 0.5, "#0000ff");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
verify(annotationRepository).save(any());
}
@Test
void createAnnotation_noConflict_whenAnnotationIsBelow() {
// x ranges overlap, but y ranges don't
// existing: x=0.0, w=0.5, y=0.5, h=0.2 (ey2=0.7)
// dto: x=0.1, w=0.3 (dx2=0.4), y=0.0, h=0.4 (dy2=0.4)
// existing.getX() < dx2 → 0.0 < 0.4 → TRUE
// ex2 > dto.getX() → 0.5 > 0.1 → TRUE
// existing.getY() < dy2 → 0.5 < 0.4 → FALSE → no overlap (third && fails)
UUID docId = UUID.randomUUID();
DocumentAnnotation existing = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.0).y(0.5).width(0.5).height(0.2).color("#ff0000").build();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.0, 0.3, 0.4, "#0000ff");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
verify(annotationRepository).save(any());
}
@Test
void createAnnotation_noConflict_whenAnnotationIsAbove() {
// x ranges overlap, y ranges don't — existing is ABOVE the new annotation
// existing: x=0.0, w=0.5, y=0.0, h=0.1 (ey2=0.1)
// dto: x=0.1, w=0.3 (dx2=0.4), y=0.2, h=0.3 (dy2=0.5)
// A: 0.0 < 0.4 → TRUE, B: 0.5 > 0.1 → TRUE, C: 0.0 < 0.5 → TRUE
// D: ey2 > dto.getY() → 0.1 > 0.2 → FALSE → no overlap (fourth && fails)
UUID docId = UUID.randomUUID();
DocumentAnnotation existing = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.0).y(0.0).width(0.5).height(0.1).color("#ff0000").build();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.2, 0.3, 0.3, "#0000ff");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
verify(annotationRepository).save(any());
}
}

View File

@@ -1,491 +0,0 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentComment;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.CommentRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
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.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@ExtendWith(MockitoExtension.class)
class CommentServiceTest {
@Mock CommentRepository commentRepository;
@Mock UserService userService;
@Mock NotificationService notificationService;
@InjectMocks CommentService commentService;
// ─── postComment ──────────────────────────────────────────────────────────
@Test
void postComment_capturesAuthorNameAtWriteTime() {
UUID docId = UUID.randomUUID();
AppUser author = AppUser.builder()
.id(UUID.randomUUID()).username("hans").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()).username("hans42").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()).username("hans").firstName("Hans").lastName("M").build();
AppUser mentioned = AppUser.builder().id(mentionedId).username("anna").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 ───────────────────────────────────────────────────────
@Test
void replyToComment_throwsNotFound_whenTargetCommentMissing() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", List.of(), author))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
verify(commentRepository, never()).save(any());
}
@Test
void replyToComment_resolvesToRootParent_whenReplyingToAReply() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
UUID replyId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
DocumentComment existingReply = DocumentComment.builder()
.id(replyId).documentId(docId).parentId(rootId).content("Reply1").authorName("Anna").build();
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(existingReply));
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", List.of(), author);
assertThat(result.getParentId()).isEqualTo(rootId);
}
@Test
void replyToComment_usesDirectComment_whenReplyingToTopLevel() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
when(commentRepository.save(any())).thenReturn(saved);
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
assertThat(result.getParentId()).isEqualTo(rootId);
}
@Test
void replyToComment_triggersNotifyReply_afterSave() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
when(commentRepository.save(any())).thenReturn(saved);
commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
verify(notificationService).notifyReply(eq(saved), anySet());
}
@Test
void replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
UUID mentionedId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
AppUser mentioned = AppUser.builder().id(mentionedId).username("bob").firstName("Bob").lastName("J").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Hey @Bob J").authorName("anna").build();
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
when(commentRepository.save(any())).thenReturn(saved);
commentService.replyToComment(docId, rootId, "Hey @Bob J", List.of(mentionedId), author);
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
}
// ─── editComment ──────────────────────────────────────────────────────────
@Test
void editComment_throwsForbidden_whenNotAuthor() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(ownerId).content("Original").authorName("Hans").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
assertThatThrownBy(() -> commentService.editComment(docId, commentId, "Changed", other))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
verify(commentRepository, never()).save(any());
}
@Test
void editComment_updatesContent_whenAuthor() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID authorId = UUID.randomUUID();
AppUser author = AppUser.builder().id(authorId).username("hans").build();
LocalDateTime created = LocalDateTime.now().minusMinutes(5);
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(authorId)
.content("Original").authorName("Hans").createdAt(created).build();
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
assertThat(result.getContent()).isEqualTo("Updated");
assertThat(result.getCreatedAt()).isEqualTo(created);
}
// ─── deleteComment ────────────────────────────────────────────────────────
@Test
void deleteComment_throwsForbidden_whenNotAuthorAndNotAdmin() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
assertThatThrownBy(() -> commentService.deleteComment(docId, commentId, other))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
verify(commentRepository, never()).delete(any());
}
@Test
void deleteComment_succeeds_whenAuthor() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID authorId = UUID.randomUUID();
AppUser author = AppUser.builder().id(authorId).username("hans").build();
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(authorId).authorName("Hans").content("X").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
commentService.deleteComment(docId, commentId, author);
verify(commentRepository).delete(comment);
}
@Test
void deleteComment_succeeds_whenAdmin() {
UUID docId = UUID.randomUUID();
UUID commentId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
AppUser admin = buildAdmin();
DocumentComment comment = DocumentComment.builder()
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
commentService.deleteComment(docId, commentId, admin);
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 ─────────────────
@Test
void replyToComment_handlesNullAuthorId_inExistingReply() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").firstName("Anna").lastName("S").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID()).content("Root").authorName("Root").build();
// Existing reply with null authorId
DocumentComment nullAuthorReply = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorId(null).content("Anon reply").authorName("anon").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("New reply").authorName("Anna S").build();
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(nullAuthorReply));
when(commentRepository.save(any())).thenReturn(saved);
commentService.replyToComment(docId, rootId, "New reply", List.of(), author);
// Must not throw NullPointerException; only non-null authorIds collected
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()).username("user42")
.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()).username("user42")
.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()).username("user42")
.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()).username("user42")
.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()).username("hans")
.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 ──────────────────
@Test
void replyToComment_includesNonNullAuthorId_fromExistingReply() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
UUID existingReplyAuthorId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID())
.content("Root").authorName("root").build();
// Existing reply WITH a non-null authorId — covers true branch of reply.getAuthorId() != null
DocumentComment existingReply = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId)
.authorId(existingReplyAuthorId).content("Existing").authorName("someone").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId)
.content("New reply").authorName("anna").build();
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(existingReply));
when(commentRepository.save(any())).thenReturn(saved);
commentService.replyToComment(docId, rootId, "New reply", List.of(), author);
verify(notificationService).notifyReply(eq(saved), anySet());
}
// ─── collectParticipantIds — null authorId ────────────────────────────────
@Test
void replyToComment_excludesNullAuthorIds_fromParticipantSet() {
UUID docId = UUID.randomUUID();
UUID rootId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
// Root with null authorId
DocumentComment root = DocumentComment.builder()
.id(rootId).documentId(docId).parentId(null).authorId(null).content("Root").authorName("anon").build();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
when(commentRepository.save(any())).thenReturn(saved);
// Must not throw NullPointerException
commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
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 ──────────────────────────────────────────────────────────────
private AppUser buildAdmin() {
return AppUser.builder()
.id(UUID.randomUUID())
.username("admin")
.groups(Set.of(UserGroup.builder()
.id(UUID.randomUUID())
.name("admins")
.permissions(Set.of("ADMIN"))
.build()))
.build();
}
}

View File

@@ -1,120 +0,0 @@
package org.raddatz.familienarchiv.service;
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.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CustomUserDetailsServiceTest {
@Mock AppUserRepository userRepository;
@InjectMocks CustomUserDetailsService service;
// ─── loadUserByUsername — not found ──────────────────────────────────────
@Test
void loadUserByUsername_throwsUsernameNotFoundException_whenUserNotFound() {
when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.loadUserByUsername("ghost"))
.isInstanceOf(UsernameNotFoundException.class)
.hasMessageContaining("ghost");
}
// ─── loadUserByUsername — happy path ─────────────────────────────────────
@Test
void loadUserByUsername_returnsUserDetails_withMappedAuthorities() {
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins")
.permissions(Set.of("READ_ALL", "WRITE_ALL")).build();
AppUser user = AppUser.builder().id(UUID.randomUUID())
.username("admin").password("hashed").enabled(true)
.groups(Set.of(group)).build();
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
UserDetails details = service.loadUserByUsername("admin");
assertThat(details.getUsername()).isEqualTo("admin");
assertThat(details.getAuthorities()).extracting("authority")
.contains("READ_ALL", "WRITE_ALL");
}
@Test
void loadUserByUsername_returnsEmptyAuthorities_whenUserHasNoGroups() {
AppUser user = AppUser.builder().id(UUID.randomUUID())
.username("viewer").password("hashed").enabled(true)
.groups(Set.of()).build();
when(userRepository.findByUsername("viewer")).thenReturn(Optional.of(user));
UserDetails details = service.loadUserByUsername("viewer");
assertThat(details.getAuthorities()).isEmpty();
}
// ─── loadUserByUsername — unknown permission ──────────────────────────────
@Test
void loadUserByUsername_grantsUnknownPermission_butLogsWarning() {
// Unknown permissions should still be granted (logged as warning, not silently dropped)
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("CustomGroup")
.permissions(Set.of("UNKNOWN_CUSTOM_PERM")).build();
AppUser user = AppUser.builder().id(UUID.randomUUID())
.username("custom").password("hashed").enabled(true)
.groups(Set.of(group)).build();
when(userRepository.findByUsername("custom")).thenReturn(Optional.of(user));
UserDetails details = service.loadUserByUsername("custom");
assertThat(details.getAuthorities()).extracting("authority")
.contains("UNKNOWN_CUSTOM_PERM");
}
// ─── loadUserByUsername — disabled user ───────────────────────────────────
@Test
void loadUserByUsername_returnsDisabledUser_whenUserIsDisabled() {
AppUser user = AppUser.builder().id(UUID.randomUUID())
.username("disabled").password("hashed").enabled(false)
.groups(Set.of()).build();
when(userRepository.findByUsername("disabled")).thenReturn(Optional.of(user));
UserDetails details = service.loadUserByUsername("disabled");
assertThat(details.isEnabled()).isFalse();
}
// ─── loadUserByUsername — multi-group permission merge ────────────────────
@Test
void loadUserByUsername_mergesPermissionsFromMultipleGroups() {
UserGroup g1 = UserGroup.builder().id(UUID.randomUUID()).name("Readers")
.permissions(Set.of("READ_ALL")).build();
UserGroup g2 = UserGroup.builder().id(UUID.randomUUID()).name("Writers")
.permissions(Set.of("WRITE_ALL")).build();
AppUser user = AppUser.builder().id(UUID.randomUUID())
.username("multi").password("hashed").enabled(true)
.groups(Set.of(g1, g2)).build();
when(userRepository.findByUsername("multi")).thenReturn(Optional.of(user));
UserDetails details = service.loadUserByUsername("multi");
assertThat(details.getAuthorities()).extracting("authority")
.containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
}
}

View File

@@ -374,366 +374,6 @@ class DocumentVersionServiceTest {
assertThat(count).isEqualTo(2);
}
// ─── recordVersion — no auth / user not found ─────────────────────────────
@Test
void recordVersion_usesUnknown_whenSecurityContextHasNoAuthentication() {
// No call to authenticateAs — context is cleared
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.recordVersion(minimalDocument());
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("Unknown");
assertThat(captor.getValue().getEditorId()).isNull();
}
@Test
void recordVersion_usesUnknown_whenAuthenticationIsNotAuthenticated() {
// Auth present but isAuthenticated() = false — use TestingAuthenticationToken
org.springframework.security.authentication.TestingAuthenticationToken notAuth =
new org.springframework.security.authentication.TestingAuthenticationToken("user", null);
notAuth.setAuthenticated(false);
SecurityContextHolder.getContext().setAuthentication(notAuth);
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.recordVersion(minimalDocument());
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("Unknown");
}
@Test
void recordVersion_usesUnknown_whenUserServiceThrows() {
authenticateAs("missinguser");
when(userService.findByUsername("missinguser")).thenThrow(new RuntimeException("not found"));
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.recordVersion(minimalDocument());
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("Unknown");
}
// ─── recordVersion — buildEditorName edge cases ───────────────────────────
@Test
void recordVersion_usesUsername_whenFirstNameIsNotBlankButLastNameIsNull() {
authenticateAs("user42");
when(userService.findByUsername("user42")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).username("user42")
.firstName("Hans").lastName(null).build());
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.recordVersion(minimalDocument());
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
}
@Test
void recordVersion_usesUsername_whenFirstNameIsBlankButLastNameIsPresent() {
authenticateAs("user42");
when(userService.findByUsername("user42")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).username("user42")
.firstName(" ").lastName("Müller").build());
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.recordVersion(minimalDocument());
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
}
@Test
void recordVersion_usesUsername_whenLastNameIsBlankButFirstNameIsPresent() {
authenticateAs("user42");
when(userService.findByUsername("user42")).thenReturn(
AppUser.builder().id(UUID.randomUUID()).username("user42")
.firstName("Hans").lastName(" ").build());
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.recordVersion(minimalDocument());
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
}
// ─── recordVersion — computeChangedFields with corrupt snapshot ──────────
@Test
void recordVersion_returnsEmptyChangedFields_whenPreviousSnapshotIsInvalidJson() {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
UUID docId = UUID.randomUUID();
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot("INVALID JSON")
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.recordVersion(Document.builder().id(docId).title("T").build());
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).isEqualTo("[]");
}
// ─── recordVersion — checkSender/checkReceivers/checkTags with no previous ─
@Test
void recordVersion_tracksSenderAdded_whenPreviousHadNoSender() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
Document oldDoc = Document.builder().id(docId).title("T").build(); // no sender
String oldSnapshot = mapper.writeValueAsString(oldDoc);
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person newSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
Document updated = Document.builder().id(docId).title("T").sender(newSender).build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("sender");
}
@Test
void recordVersion_tracksReceiversAdded_whenPreviousHadNone() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
Document oldDoc = Document.builder().id(docId).title("T").build(); // no receivers
String oldSnapshot = mapper.writeValueAsString(oldDoc);
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person r = Person.builder().id(UUID.randomUUID()).firstName("C").lastName("D").build();
Document updated = Document.builder().id(docId).title("T").receivers(Set.of(r)).build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("receivers");
}
@Test
void recordVersion_tracksTagsAdded_whenPreviousHadNone() throws Exception {
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
Document oldDoc = Document.builder().id(docId).title("T").build(); // no tags
String oldSnapshot = mapper.writeValueAsString(oldDoc);
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
Document updated = Document.builder().id(docId).title("T").tags(Set.of(tag)).build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("tags");
}
// ─── checkSender — sender map with null id ───────────────────────────────
@Test
void recordVersion_senderChangedToPresent_whenPreviousSenderHasNullId() throws Exception {
// Covers: prevSender instanceof Map = true, but id == null → prevId = null
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
UUID docId = UUID.randomUUID();
// Manually craft a JSON where sender object exists but id is null
String oldSnapshot = "{\"id\":\"" + docId + "\",\"title\":\"T\","
+ "\"sender\":{\"id\":null,\"firstName\":\"A\",\"lastName\":\"B\"},"
+ "\"receivers\":[],\"tags\":[]}";
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person newSender = Person.builder().id(UUID.randomUUID()).firstName("B").lastName("C").build();
Document updated = Document.builder().id(docId).title("T").sender(newSender).build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("sender");
}
// ─── checkSender — sender unchanged → not in changedFields ───────────────
@Test
void recordVersion_doesNotTrackSender_whenSenderUnchanged() throws Exception {
// Covers: !Objects.equals(currentId, prevId) = false → don't add "sender"
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
UUID senderId = UUID.randomUUID();
Person sender = Person.builder().id(senderId).firstName("A").lastName("B").build();
Document oldDoc = Document.builder().id(docId).title("T").sender(sender).build();
String oldSnapshot = mapper.writeValueAsString(oldDoc);
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
// Same sender — should NOT be in changedFields
Document updated = Document.builder().id(docId).title("T").sender(sender).build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).doesNotContain("sender");
}
// ─── computeChangedFields — documentDate ternary true branch ─────────────
@Test
void recordVersion_tracksDocumentDate_whenCurrentDocHasNonNullDate() throws Exception {
// current.getDocumentDate() != null = true → ternary true branch in computeChangedFields
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
ObjectMapper mapper = new ObjectMapper();
UUID docId = UUID.randomUUID();
Document oldDoc = Document.builder().id(docId).title("T").build(); // no date in previous
String oldSnapshot = mapper.writeValueAsString(oldDoc);
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
// Current doc has a non-null documentDate → ternary evaluates its true branch
Document updated = Document.builder().id(docId).title("T")
.documentDate(LocalDate.of(1965, 3, 12)).build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("documentDate");
}
// ─── checkReceivers / checkTags — when previous snapshot has null values ───
@Test
void recordVersion_tracksReceivers_whenPreviousSnapshotHasNullReceivers() throws Exception {
// prevReceivers NOT instanceof List<?> → prevIds = Set.of() → if currentIds differ → added
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
UUID docId = UUID.randomUUID();
// Craft snapshot where "receivers" is JSON null → deserialized as null, NOT a List
String oldSnapshot = "{\"id\":\"" + docId + "\",\"title\":\"T\",\"receivers\":null,\"tags\":[]}";
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person r = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
Document updated = Document.builder().id(docId).title("T").receivers(Set.of(r)).build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("receivers");
}
@Test
void recordVersion_tracksTags_whenPreviousSnapshotHasNullTags() throws Exception {
// prevTags NOT instanceof List<?> → prevNames = Set.of() → if currentNames differ → added
authenticateAs("user1");
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
UUID docId = UUID.randomUUID();
// Craft snapshot where "tags" is JSON null → deserialized as null, NOT a List
String oldSnapshot = "{\"id\":\"" + docId + "\",\"title\":\"T\",\"receivers\":[],\"tags\":null}";
DocumentVersion previous = DocumentVersion.builder()
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
.editorName("user1").build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
Document updated = Document.builder().id(docId).title("T").tags(Set.of(tag)).build();
versionService.recordVersion(updated);
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getChangedFields()).contains("tags");
}
// ─── backfill — uses LocalDateTime.now() when createdAt is null ──────────
@Test
void backfill_usesNow_whenDocumentCreatedAtIsNull() {
Document doc = Document.builder().id(UUID.randomUUID()).title("T").createdAt(null).build();
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
versionService.backfillMissingVersions(List.of(doc));
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
verify(versionRepository).save(captor.capture());
assertThat(captor.getValue().getSavedAt()).isNotNull();
}
// ─── helpers ──────────────────────────────────────────────────────────────
private void authenticateAs(String username) {

View File

@@ -1,200 +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.springframework.mock.web.MockMultipartFile;
import software.amazon.awssdk.core.ResponseInputStream;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.http.AbortableInputStream;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
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 FileServiceTest {
private S3Client s3Client;
private FileService fileService;
@BeforeEach
void setUp() {
s3Client = mock(S3Client.class);
fileService = new FileService(s3Client, "test-bucket");
}
@Test
void uploadFile_returnsS3Key() throws IOException {
MockMultipartFile file = new MockMultipartFile(
"file", "test.pdf", "application/pdf", new byte[]{1, 2, 3});
FileService.UploadResult result = fileService.uploadFile(file, "test.pdf");
assertThat(result.s3Key()).startsWith("documents/");
assertThat(result.s3Key()).endsWith("_test.pdf");
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException {
byte[] content = "hello pdf content".getBytes();
MockMultipartFile file = new MockMultipartFile(
"file", "doc.pdf", "application/pdf", content);
FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf");
// Compute expected hash independently
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(content);
StringBuilder expected = new StringBuilder();
for (byte b : hashBytes) {
expected.append(String.format("%02x", b));
}
assertThat(result.fileHash()).isEqualTo(expected.toString());
}
@Test
void uploadFile_differentContents_produceDifferentHashes() throws IOException {
MockMultipartFile file1 = new MockMultipartFile(
"f", "a.pdf", "application/pdf", new byte[]{1, 2, 3});
MockMultipartFile file2 = new MockMultipartFile(
"f", "b.pdf", "application/pdf", new byte[]{4, 5, 6});
FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf");
assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash());
}
@Test
void uploadFile_sameContents_produceSameHash() throws IOException {
byte[] content = new byte[]{10, 20, 30};
MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content);
MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content);
FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf");
FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf");
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
}
@Test
void uploadFile_throwsIOException_whenS3Throws() {
MockMultipartFile file = new MockMultipartFile("f", "fail.pdf", "application/pdf", new byte[]{1});
S3Exception s3ex = (S3Exception) S3Exception.builder().message("bucket error").statusCode(500).build();
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))).thenThrow(s3ex);
assertThatThrownBy(() -> fileService.uploadFile(file, "fail.pdf"))
.isInstanceOf(IOException.class)
.hasMessageContaining("Failed to upload");
}
// ─── downloadFile ─────────────────────────────────────────────────────────
@Test
void downloadFile_returnsResourceWithContentType() {
byte[] content = "pdf content".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);
FileService.S3FileDownload result = fileService.downloadFile("documents/test.pdf");
assertThat(result.contentType()).isEqualTo("application/pdf");
assertThat(result.resource()).isNotNull();
}
@Test
void downloadFile_fallsBackToOctetStream_whenContentTypeIsBlank() {
byte[] content = "data".getBytes();
GetObjectResponse response = GetObjectResponse.builder().contentType(" ").build();
ResponseInputStream<GetObjectResponse> stream = new ResponseInputStream<>(
response, AbortableInputStream.create(new ByteArrayInputStream(content)));
when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
FileService.S3FileDownload result = fileService.downloadFile("documents/file");
assertThat(result.contentType()).isEqualTo("application/octet-stream");
}
@Test
void downloadFile_fallsBackToOctetStream_whenContentTypeIsNull() {
byte[] content = "data".getBytes();
GetObjectResponse response = GetObjectResponse.builder().build(); // no contentType
ResponseInputStream<GetObjectResponse> stream = new ResponseInputStream<>(
response, AbortableInputStream.create(new ByteArrayInputStream(content)));
when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
FileService.S3FileDownload result = fileService.downloadFile("documents/file");
assertThat(result.contentType()).isEqualTo("application/octet-stream");
}
@Test
void downloadFile_throwsStorageFileNotFoundException_whenNoSuchKey() {
NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build();
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
assertThatThrownBy(() -> fileService.downloadFile("missing/key.pdf"))
.isInstanceOf(FileService.StorageFileNotFoundException.class)
.hasMessageContaining("missing/key.pdf");
}
@Test
void downloadFile_throwsRuntimeException_whenS3Exception() {
S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build();
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
assertThatThrownBy(() -> fileService.downloadFile("documents/file.pdf"))
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("Storage Error");
}
// ─── downloadFileBytes ────────────────────────────────────────────────────
@Test
void downloadFileBytes_returnsRawBytes() throws IOException {
byte[] content = "raw bytes".getBytes();
GetObjectResponse response = GetObjectResponse.builder().build();
ResponseInputStream<GetObjectResponse> stream = new ResponseInputStream<>(
response, AbortableInputStream.create(new ByteArrayInputStream(content)));
when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
byte[] result = fileService.downloadFileBytes("documents/file.pdf");
assertThat(result).isEqualTo(content);
}
@Test
void downloadFileBytes_throwsStorageFileNotFoundException_whenNoSuchKey() {
NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build();
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
assertThatThrownBy(() -> fileService.downloadFileBytes("missing/key.pdf"))
.isInstanceOf(FileService.StorageFileNotFoundException.class);
}
@Test
void downloadFileBytes_throwsIOException_whenS3Exception() {
S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build();
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
assertThatThrownBy(() -> fileService.downloadFileBytes("documents/file.pdf"))
.isInstanceOf(IOException.class)
.hasMessageContaining("Failed to download");
}
}

View File

@@ -1,504 +0,0 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
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 java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MassImportServiceTest {
@Mock DocumentRepository documentRepository;
@Mock PersonService personService;
@Mock TagService tagService;
@Mock S3Client s3Client;
MassImportService service;
@BeforeEach
void setUp() {
service = new MassImportService(documentRepository, personService, tagService, s3Client);
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
ReflectionTestUtils.setField(service, "colIndex", 0);
ReflectionTestUtils.setField(service, "colBox", 1);
ReflectionTestUtils.setField(service, "colFolder", 2);
ReflectionTestUtils.setField(service, "colSender", 3);
ReflectionTestUtils.setField(service, "colReceivers", 5);
ReflectionTestUtils.setField(service, "colDate", 7);
ReflectionTestUtils.setField(service, "colLocation", 9);
ReflectionTestUtils.setField(service, "colTags", 10);
ReflectionTestUtils.setField(service, "colSummary", 11);
ReflectionTestUtils.setField(service, "colTranscription", 13);
}
// ─── getStatus ────────────────────────────────────────────────────────────
@Test
void getStatus_returnsIdleByDefault() {
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
}
// ─── runImportAsync ───────────────────────────────────────────────────────
@Test
void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
// /import directory doesn't exist in test environment → findSpreadsheetFile throws
service.runImportAsync();
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
}
@Test
void runImportAsync_throwsConflict_whenAlreadyRunning() {
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
MassImportService.State.RUNNING, "Running...", 0, LocalDateTime.now());
ReflectionTestUtils.setField(service, "currentStatus", running);
assertThatThrownBy(() -> service.runImportAsync())
.isInstanceOf(DomainException.class)
.hasMessageContaining("already in progress");
}
// ─── importSingleDocument — skip already uploaded ─────────────────────────
@Test
void importSingleDocument_skips_whenDocumentAlreadyUploadedNotPlaceholder() {
Document existing = Document.builder()
.id(UUID.randomUUID())
.originalFilename("doc001.pdf")
.status(DocumentStatus.UPLOADED)
.build();
when(documentRepository.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
verify(documentRepository, never()).save(any());
}
// ─── importSingleDocument — create new document (metadata only) ───────────
@Test
void importSingleDocument_createsNewDocument_whenNotExists() {
when(documentRepository.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.importSingleDocument(minimalCells("doc002.pdf"), Optional.empty(), "doc002.pdf", "doc002");
verify(documentRepository).save(argThat(d ->
d.getOriginalFilename().equals("doc002.pdf")
&& d.getStatus() == DocumentStatus.PLACEHOLDER));
}
// ─── importSingleDocument — update existing placeholder ──────────────────
@Test
void importSingleDocument_updatesExistingPlaceholder() {
Document placeholder = Document.builder()
.id(UUID.randomUUID())
.originalFilename("existing.pdf")
.status(DocumentStatus.PLACEHOLDER)
.build();
when(documentRepository.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(placeholder));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.importSingleDocument(minimalCells("existing.pdf"), Optional.empty(), "existing.pdf", "existing");
verify(documentRepository).save(same(placeholder));
}
// ─── importSingleDocument — with file (S3 upload) ─────────────────────────
@Test
void importSingleDocument_uploadsFileToS3_andSetsStatusUploaded(@TempDir Path tempDir) throws Exception {
Path tempFile = tempDir.resolve("doc003.pdf");
Files.write(tempFile, "PDF content".getBytes());
when(documentRepository.findByOriginalFilename("doc003.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.importSingleDocument(
minimalCells("doc003.pdf"), Optional.of(tempFile.toFile()), "doc003.pdf", "doc003");
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
verify(documentRepository).save(argThat(d -> d.getStatus() == DocumentStatus.UPLOADED));
}
@Test
void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
Path tempFile = tempDir.resolve("fail.pdf");
Files.write(tempFile, "data".getBytes());
when(documentRepository.findByOriginalFilename("fail.pdf")).thenReturn(Optional.empty());
doThrow(new RuntimeException("S3 error"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
service.importSingleDocument(
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
verify(documentRepository, never()).save(any());
}
// ─── importSingleDocument — sender handling ───────────────────────────────
@Test
void importSingleDocument_setsNullSender_whenSenderCellIsBlank() {
when(documentRepository.findByOriginalFilename("nosender.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("nosender.pdf", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "nosender.pdf", "nosender");
verify(documentRepository).save(argThat(d -> d.getSender() == null));
verify(personService, never()).findOrCreateByAlias(any());
}
@Test
void importSingleDocument_createsSender_whenSenderCellIsNonBlank() {
Person sender = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
when(documentRepository.findByOriginalFilename("withsender.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(sender);
List<String> cells = buildCells("withsender.pdf", "Walter Müller", "", "");
service.importSingleDocument(cells, Optional.empty(), "withsender.pdf", "withsender");
verify(personService).findOrCreateByAlias("Walter Müller");
verify(documentRepository).save(argThat(d -> d.getSender() == sender));
}
// ─── importSingleDocument — tag handling ─────────────────────────────────
@Test
void importSingleDocument_createsTag_whenTagCellIsNonBlank() {
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
when(documentRepository.findByOriginalFilename("tagged.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(tagService.findOrCreate("Familie")).thenReturn(tag);
List<String> cells = buildCells("tagged.pdf", "", "", "Familie");
service.importSingleDocument(cells, Optional.empty(), "tagged.pdf", "tagged");
verify(tagService).findOrCreate("Familie");
}
@Test
void importSingleDocument_doesNotCreateTag_whenTagCellIsBlank() {
when(documentRepository.findByOriginalFilename("notag.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("notag.pdf", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "notag.pdf", "notag");
verify(tagService, never()).findOrCreate(any());
}
// ─── importSingleDocument — metadataComplete heuristic ───────────────────
@Test
void importSingleDocument_metadataComplete_whenSenderPresent() {
Person sender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
when(documentRepository.findByOriginalFilename("meta.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.findOrCreateByAlias("A B")).thenReturn(sender);
List<String> cells = buildCells("meta.pdf", "A B", "", "");
service.importSingleDocument(cells, Optional.empty(), "meta.pdf", "meta");
verify(documentRepository).save(argThat(Document::isMetadataComplete));
}
@Test
void importSingleDocument_metadataIncomplete_whenNoKeyFieldsPresent() {
when(documentRepository.findByOriginalFilename("nometa.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("nometa.pdf", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "nometa.pdf", "nometa");
verify(documentRepository).save(argThat(d -> !d.isMetadataComplete()));
}
// ─── importSingleDocument — blank fields set to null ─────────────────────
@Test
void importSingleDocument_setsBlankFieldsToNull() {
when(documentRepository.findByOriginalFilename("blank.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("blank.pdf", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "blank.pdf", "blank");
verify(documentRepository).save(argThat(d ->
d.getLocation() == null &&
d.getSummary() == null &&
d.getTranscription() == null &&
d.getArchiveBox() == null &&
d.getArchiveFolder() == null));
}
// ─── processRows — via ReflectionTestUtils ────────────────────────────────
@Test
void processRows_returnsZero_whenOnlyHeaderRow() {
List<List<String>> rows = List.of(List.of("header", "col1"));
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(0);
}
@Test
void processRows_skipsRowWithBlankIndex() {
List<List<String>> rows = List.of(
List.of("header"),
minimalCells("") // blank index
);
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(0);
verify(documentRepository, never()).findByOriginalFilename(any());
}
@Test
void processRows_addsExtension_whenIndexHasNoDot() {
when(documentRepository.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<List<String>> rows = List.of(
List.of("header"),
minimalCells("doc001") // no dot → appends ".pdf"
);
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(1);
verify(documentRepository).findByOriginalFilename("doc001.pdf");
}
@Test
void processRows_usesFilenameAsIs_whenIndexHasDot() {
when(documentRepository.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<List<String>> rows = List.of(
List.of("header"),
minimalCells("doc002.pdf") // has dot → used as-is
);
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(1);
verify(documentRepository).findByOriginalFilename("doc002.pdf");
}
// ─── importSingleDocument — non-blank optional fields ────────────────────
@Test
void importSingleDocument_setsNonNullOptionalFields_whenPresent() {
when(documentRepository.findByOriginalFilename("rich.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
// box=1, folder=2, location=9, summary=11, transcription=13
List<String> cells = List.of(
"rich.pdf", // 0: index
"Box A", // 1: box
"Folder B", // 2: folder
"", // 3: sender
"", // 4: unused
"", // 5: receivers
"", // 6: unused
"", // 7: date
"", // 8: unused
"Hamburg", // 9: location
"", // 10: tags
"A summary", // 11: summary
"", // 12: unused
"A transcript" // 13: transcription
);
service.importSingleDocument(cells, Optional.empty(), "rich.pdf", "rich");
verify(documentRepository).save(argThat(d ->
"Box A".equals(d.getArchiveBox()) &&
"Folder B".equals(d.getArchiveFolder()) &&
"Hamburg".equals(d.getLocation()) &&
"A summary".equals(d.getSummary()) &&
"A transcript".equals(d.getTranscription())));
}
@Test
void importSingleDocument_setsMetadataComplete_whenReceiversArePresent() {
Person receiver = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
when(documentRepository.findByOriginalFilename("rcv.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(receiver);
List<String> cells = List.of(
"rcv.pdf", "", "", "", "", "Walter Müller", "", "", "", "", "", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "rcv.pdf", "rcv");
verify(documentRepository).save(argThat(Document::isMetadataComplete));
}
@Test
void importSingleDocument_setsMetadataComplete_whenDateIsPresent() {
when(documentRepository.findByOriginalFilename("dated.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = List.of(
"dated.pdf", "", "", "", "", "", "", "2024-03-15", "", "", "", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "dated.pdf", "dated");
verify(documentRepository).save(argThat(Document::isMetadataComplete));
}
// ─── buildTitle — null location ───────────────────────────────────────────
@Test
void buildTitle_withNullLocation_skipsLocationPart() {
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
"doc005", LocalDate.of(1940, 5, 1), (String) null);
assertThat(result).contains("doc005").contains("1940");
assertThat(result).doesNotContain("Berlin");
}
// ─── parseDate — via ReflectionTestUtils ─────────────────────────────────
@Test
void parseDate_returnsNull_whenValueIsNull() {
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", (String) null);
assertThat(result).isNull();
}
@Test
void parseDate_returnsNull_whenValueIsBlank() {
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", " ");
assertThat(result).isNull();
}
@Test
void parseDate_returnsDate_whenValidIsoFormat() {
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", "2024-03-15");
assertThat(result).isEqualTo(LocalDate.of(2024, 3, 15));
}
@Test
void parseDate_returnsNull_whenInvalidDateString() {
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", "15.03.2024");
assertThat(result).isNull();
}
// ─── buildTitle — via ReflectionTestUtils ────────────────────────────────
@Test
void buildTitle_withDateAndLocation() {
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
"doc001", LocalDate.of(1940, 5, 1), "Berlin");
assertThat(result).contains("doc001").contains("Berlin").contains("1940");
}
@Test
void buildTitle_withDateOnly() {
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
"doc002", LocalDate.of(1960, 8, 15), "");
assertThat(result).contains("doc002").contains("1960");
assertThat(result).doesNotContain("Berlin");
}
@Test
void buildTitle_withIndexOnly_whenDateAndLocationAreNull() {
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
"doc003", null, "");
assertThat(result).isEqualTo("doc003");
}
@Test
void buildTitle_withLocationOnly_whenDateIsNull() {
// date=null, location present → date part skipped, location appended
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
"doc004", null, "Berlin");
assertThat(result).contains("doc004").contains("Berlin");
assertThat(result).doesNotContain("("); // no date part
}
// ─── getCell — via ReflectionTestUtils ───────────────────────────────────
@Test
void getCell_returnsEmptyString_whenColBeyondListSize() {
List<String> cells = List.of("a", "b");
String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 5);
assertThat(result).isEmpty();
}
@Test
void getCell_returnsEmptyString_whenValueIsNull() {
List<String> cells = new ArrayList<>();
cells.add(null);
cells.add("b");
String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 0);
assertThat(result).isEmpty();
}
@Test
void getCell_returnsTrimmedValue() {
List<String> cells = List.of(" hello ", "world");
String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 0);
assertThat(result).isEqualTo("hello");
}
// ─── helpers ──────────────────────────────────────────────────────────────
/**
* Builds a minimal 14-element cell row with the given filename at index 0
* and blanks for all optional fields.
*/
private List<String> minimalCells(String filename) {
return buildCells(filename, "", "", "");
}
/**
* Builds a cell row with sender, receiver, and tag controls.
* Layout matches the default column indices set in setUp().
*/
private List<String> buildCells(String filename, String sender, String receivers, String tag) {
// 14 elements: index=0,box=1,folder=2,sender=3,[4],receivers=5,[6],date=7,[8],location=9,tag=10,summary=11,[12],transcription=13
return List.of(
filename, // 0: index
"", // 1: box
"", // 2: folder
sender, // 3: sender
"", // 4: (unused)
receivers, // 5: receivers
"", // 6: (unused)
"", // 7: date
"", // 8: (unused)
"", // 9: location
tag, // 10: tags
"", // 11: summary
"", // 12: (unused)
"" // 13: transcription
);
}
}

View File

@@ -1,494 +0,0 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.NotificationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.*;
import org.raddatz.familienarchiv.repository.NotificationRepository;
import org.springframework.data.domain.PageImpl;
import org.springframework.mail.MailException;
import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {
@Mock NotificationRepository notificationRepository;
@Mock UserService userService;
@Mock DocumentService documentService;
@Mock JavaMailSender mailSender;
@Mock SseEmitterRegistry sseEmitterRegistry;
NotificationService notificationService;
private AppUser userA;
private AppUser userB;
private AppUser userC;
@BeforeEach
void setUp() {
notificationService = new NotificationService(notificationRepository, userService, documentService, Optional.of(mailSender), sseEmitterRegistry);
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
.firstName("Anna").lastName("Smith").email("a@test.com")
.notifyOnReply(false).notifyOnMention(false).build();
userB = AppUser.builder().id(UUID.randomUUID()).username("userB")
.firstName("Bob").lastName("Jones").email("b@test.com")
.notifyOnReply(false).notifyOnMention(false).build();
userC = AppUser.builder().id(UUID.randomUUID()).username("userC")
.firstName("Clara").lastName("Doe").email("c@test.com")
.notifyOnReply(false).notifyOnMention(false).build();
}
// ─── notifyReply ──────────────────────────────────────────────────────────
@Test
void notifyReply_createsNotificationForThreadParticipants() {
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
verify(notificationRepository, times(2)).save(captor.capture());
List<Notification> saved = captor.getAllValues();
assertThat(saved).extracting(n -> n.getRecipient().getId())
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
assertThat(saved).allMatch(n -> n.getType() == NotificationType.REPLY);
assertThat(saved).allMatch(n -> !n.isRead());
assertThat(saved).allMatch(n -> "Clara Doe".equals(n.getActorName()));
}
@Test
void notifyReply_doesNothing_whenParticipantSetIsEmpty() {
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
notificationService.notifyReply(reply, Set.of());
verify(notificationRepository, never()).save(any());
}
@Test
void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() {
userA.setNotifyOnReply(true);
userB.setNotifyOnReply(false);
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
}
// ─── notifyMentions ───────────────────────────────────────────────────────
@Test
void notifyMentions_createsNotificationPerMentionedUser() {
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
verify(notificationRepository, times(2)).save(captor.capture());
List<Notification> saved = captor.getAllValues();
assertThat(saved).extracting(n -> n.getRecipient().getId())
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
assertThat(saved).allMatch(n -> n.getType() == NotificationType.MENTION);
assertThat(saved).allMatch(n -> "Clara Doe".equals(n.getActorName()));
}
@Test
void notifyMentions_doesNothing_whenListIsEmpty() {
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
notificationService.notifyMentions(List.of(), comment);
verify(notificationRepository, never()).save(any());
}
@Test
void notifyMentions_sendsEmailOnlyToUsersWithMentionNotificationsEnabled() {
userA.setNotifyOnMention(true);
userB.setNotifyOnMention(false);
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
}
// ─── SSE push ─────────────────────────────────────────────────────────────
@Test
void notifyReply_pushesEventToRegistry_forEachRecipient() {
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
verify(sseEmitterRegistry).send(eq(userA.getId()), any(NotificationDTO.class));
verify(sseEmitterRegistry).send(eq(userB.getId()), any(NotificationDTO.class));
}
@Test
void notifyMentions_pushesEventToRegistry_forEachMentionedUser() {
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
verify(sseEmitterRegistry).send(eq(userA.getId()), any(NotificationDTO.class));
verify(sseEmitterRegistry).send(eq(userB.getId()), any(NotificationDTO.class));
}
// ─── markRead ─────────────────────────────────────────────────────────────
@Test
void markRead_throwsNotFound_whenNotificationDoesNotExist() {
UUID notifId = UUID.randomUUID();
when(notificationRepository.findById(notifId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> notificationService.markRead(notifId, userA.getId()))
.isInstanceOf(DomainException.class)
.hasMessageContaining("Notification not found");
}
@Test
void markRead_throwsForbidden_whenNotificationBelongsToDifferentUser() {
Notification notification = Notification.builder()
.id(UUID.randomUUID())
.recipient(userA)
.type(NotificationType.REPLY)
.read(false)
.build();
when(notificationRepository.findById(notification.getId())).thenReturn(Optional.of(notification));
assertThatThrownBy(() -> notificationService.markRead(notification.getId(), userB.getId()))
.isInstanceOf(DomainException.class)
.hasMessageContaining("different user");
}
// ─── markAllRead ──────────────────────────────────────────────────────────
@Test
void markAllRead_delegatesToRepository() {
notificationService.markAllRead(userA.getId());
verify(notificationRepository).markAllReadByRecipientId(userA.getId());
}
// ─── markRead — happy path ────────────────────────────────────────────────
@Test
void markRead_marksNotificationAsRead_whenRecipientMatches() {
Notification notification = Notification.builder()
.id(UUID.randomUUID())
.recipient(userA)
.type(NotificationType.REPLY)
.documentId(UUID.randomUUID())
.referenceId(UUID.randomUUID())
.read(false)
.build();
when(notificationRepository.findById(notification.getId())).thenReturn(Optional.of(notification));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
NotificationDTO result = notificationService.markRead(notification.getId(), userA.getId());
assertThat(result).isNotNull();
assertThat(notification.isRead()).isTrue();
}
// ─── countUnread ──────────────────────────────────────────────────────────
@Test
void countUnread_delegatesToRepository() {
when(notificationRepository.countByRecipientIdAndReadFalse(userA.getId())).thenReturn(3L);
assertThat(notificationService.countUnread(userA.getId())).isEqualTo(3L);
}
// ─── notifyMentions — null list ───────────────────────────────────────────
@Test
void notifyMentions_doesNothing_whenMentionedUserIdsIsNull() {
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
notificationService.notifyMentions(null, comment);
verify(notificationRepository, never()).save(any());
}
// ─── email — no mailSender ────────────────────────────────────────────────
@Test
void notifyReply_skipsEmail_whenMailSenderIsAbsent() {
NotificationService serviceWithoutMail = new NotificationService(
notificationRepository, userService, documentService, Optional.empty(), sseEmitterRegistry);
userA.setNotifyOnReply(true);
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
serviceWithoutMail.notifyReply(reply, Set.of(userA.getId()));
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
@Test
void notifyMentions_skipsEmail_whenMailSenderIsAbsent() {
NotificationService serviceWithoutMail = new NotificationService(
notificationRepository, userService, documentService, Optional.empty(), sseEmitterRegistry);
userA.setNotifyOnMention(true);
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userService.findAllById(List.of(userA.getId()))).thenReturn(List.of(userA));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
serviceWithoutMail.notifyMentions(List.of(userA.getId()), comment);
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
// ─── email — recipient email missing ─────────────────────────────────────
@Test
void notifyReply_skipsEmail_whenRecipientEmailIsNull() {
userA.setNotifyOnReply(true);
userA.setEmail(null);
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, Set.of(userA.getId()));
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
@Test
void notifyReply_skipsEmail_whenRecipientEmailIsBlank() {
userA.setNotifyOnReply(true);
userA.setEmail(" ");
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, Set.of(userA.getId()));
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
// ─── email — MailException swallowed ─────────────────────────────────────
@Test
void notifyReply_doesNotThrow_whenMailExceptionOccurs() {
userA.setNotifyOnReply(true);
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
doThrow(new MailSendException("SMTP down")).when(mailSender).send(any(SimpleMailMessage.class));
// Must not throw — MailException is caught and logged
notificationService.notifyReply(reply, Set.of(userA.getId()));
verify(mailSender).send(any(SimpleMailMessage.class));
}
// ─── email — annotationId included in link ────────────────────────────────
@Test
void notifyReply_includesAnnotationIdInEmailLink_whenAnnotationPresent() {
userA.setNotifyOnReply(true);
UUID annotationId = UUID.randomUUID();
DocumentComment reply = DocumentComment.builder()
.id(UUID.randomUUID())
.documentId(UUID.randomUUID())
.annotationId(annotationId)
.authorId(userC.getId())
.authorName("Clara Doe")
.content("reply")
.build();
when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
notificationService.notifyReply(reply, Set.of(userA.getId()));
ArgumentCaptor<SimpleMailMessage> captor = ArgumentCaptor.forClass(SimpleMailMessage.class);
verify(mailSender).send(captor.capture());
assertThat(captor.getValue().getText()).contains("annotationId=" + annotationId);
}
// ─── getNotifications — filter dispatch ──────────────────────────────────
@Test
void getNotifications_withNoFilters_usesUnfilteredRepoMethod() {
when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any()))
.thenReturn(Page.empty());
notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10));
verify(notificationRepository).findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any());
verify(notificationRepository, never())
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
}
@Test
void getNotifications_withTypeAndReadFalse_usesFilteredRepoMethod() {
when(notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any()))
.thenReturn(Page.empty());
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, false, Pageable.ofSize(3));
verify(notificationRepository).findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any());
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
}
@Test
void getNotifications_withTypeOnly_usesTypeFilteredRepoMethod() {
when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any()))
.thenReturn(Page.empty());
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, null, Pageable.ofSize(5));
verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any());
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
verify(notificationRepository, never())
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
}
@Test
void getNotifications_withReadFalseAndNoType_usesUnreadOnlyRepoMethod() {
when(notificationRepository.findByRecipientIdAndReadFalseOrderByCreatedAtDesc(
eq(userA.getId()), any()))
.thenReturn(Page.empty());
notificationService.getNotifications(userA.getId(), null, false, Pageable.ofSize(10));
verify(notificationRepository).findByRecipientIdAndReadFalseOrderByCreatedAtDesc(
eq(userA.getId()), any());
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
}
@Test
void getNotifications_mapsDocumentTitleFromDocumentService() {
UUID docId = UUID.randomUUID();
Notification notification = Notification.builder()
.id(UUID.randomUUID())
.recipient(userA)
.type(NotificationType.REPLY)
.documentId(docId)
.referenceId(UUID.randomUUID())
.actorName("Clara Doe")
.build();
when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any()))
.thenReturn(new PageImpl<>(List.of(notification)));
when(documentService.findTitlesByIds(Set.of(docId)))
.thenReturn(Map.of(docId, "Geburtsurkunde Opa Karl"));
Page<NotificationDTO> result = notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10));
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().getFirst().documentTitle()).isEqualTo("Geburtsurkunde Opa Karl");
}
@Test
void getNotifications_mapsDocumentTitleAsNull_whenDocumentDoesNotExist() {
UUID docId = UUID.randomUUID();
Notification notification = Notification.builder()
.id(UUID.randomUUID())
.recipient(userA)
.type(NotificationType.MENTION)
.documentId(docId)
.referenceId(UUID.randomUUID())
.actorName("Bob Jones")
.build();
when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any()))
.thenReturn(new PageImpl<>(List.of(notification)));
when(documentService.findTitlesByIds(Set.of(docId)))
.thenReturn(Map.of());
Page<NotificationDTO> result = notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10));
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().getFirst().documentTitle()).isNull();
}
@Test
void getNotifications_withTypeAndReadTrue_fallsBackToTypeOnlyQuery() {
// read=true with a type filter falls through to the type-only branch —
// it returns all notifications of that type (both read and unread).
// The read=true filter is intentionally not supported on the backend;
// callers that need only-read results must filter client-side.
when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any()))
.thenReturn(Page.empty());
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, true, Pageable.ofSize(5));
verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc(
eq(userA.getId()), eq(NotificationType.MENTION), any());
verify(notificationRepository, never())
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
}
// ─── private helpers ──────────────────────────────────────────────────────
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {
return DocumentComment.builder()
.id(id)
.documentId(UUID.randomUUID())
.parentId(parentId)
.authorId(authorId)
.authorName(authorName)
.content("content")
.build();
}
}

View File

@@ -7,7 +7,6 @@ import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doThrow;
import java.time.LocalDateTime;
import java.util.Optional;
@@ -24,11 +23,8 @@ import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
class PasswordResetServiceTest {
@@ -127,62 +123,4 @@ class PasswordResetServiceTest {
assertThatThrownBy(() -> service.resetPassword(req))
.isInstanceOf(DomainException.class);
}
@Test
void resetPassword_throwsForAlreadyUsedToken() {
AppUser user = makeUser("user@example.com");
PasswordResetToken token = PasswordResetToken.builder()
.token("usedtoken")
.user(user)
.expiresAt(LocalDateTime.now().plusHours(1))
.used(true) // already used
.build();
when(tokenRepository.findByToken("usedtoken")).thenReturn(Optional.of(token));
ResetPasswordRequest req = new ResetPasswordRequest();
req.setToken("usedtoken");
req.setNewPassword("newpass");
assertThatThrownBy(() -> service.resetPassword(req))
.isInstanceOf(DomainException.class);
}
// ─── requestReset — mail sending branches ─────────────────────────────────
@Test
void requestReset_skipsEmail_whenMailSenderIsNull() {
ReflectionTestUtils.setField(service, "mailSender", null);
AppUser user = makeUser("user@example.com");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
// Must not throw even without mail sender
service.requestReset("user@example.com", "http://localhost:3000");
verify(tokenRepository).save(any());
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
@Test
void requestReset_logsError_whenMailExceptionThrown() {
// mailSender is @Autowired(required=false) — not in constructor, so needs explicit injection
ReflectionTestUtils.setField(service, "mailSender", mailSender);
AppUser user = makeUser("user@example.com");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
doThrow(new MailSendException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class));
// Must not propagate the MailException
service.requestReset("user@example.com", "http://localhost:3000");
verify(tokenRepository).save(any());
verify(mailSender).send(any(SimpleMailMessage.class));
}
// ─── cleanupExpiredTokens ─────────────────────────────────────────────────
@Test
void cleanupExpiredTokens_delegatesToRepository() {
service.cleanupExpiredTokens();
verify(tokenRepository).deleteExpiredAndUsed(any(LocalDateTime.class));
}
}

View File

@@ -117,50 +117,4 @@ class PersonNameParserTest {
assertThat(result.firstName()).isEqualTo("?");
assertThat(result.lastName()).isEqualTo("?");
}
@Test
void split_blank_returnsPlaceholder() {
PersonNameParser.SplitName result = PersonNameParser.split(" ");
assertThat(result.firstName()).isEqualTo("?");
assertThat(result.lastName()).isEqualTo("?");
}
@Test
void split_onlyKnownLastName_firstNameFallsBackToCleaned() {
// "de Gruyter" alone → firstName would be blank after removing last name, so cleaned is used
PersonNameParser.SplitName result = PersonNameParser.split("de Gruyter");
assertThat(result.firstName()).isEqualTo("de Gruyter");
assertThat(result.lastName()).isEqualTo("de Gruyter");
}
// --- parseReceivers — shared last name with full-name part ─────────────────
@Test
void parseReceivers_partWithSpace_notAppended_whenParenLastNamePresent() {
// "Clara Cram und Hans (Müller)": Clara Cram already has a space → keep as-is
List<String> result = PersonNameParser.parseReceivers("Clara Cram und Hans (Müller)");
assertThat(result).containsExactlyInAnyOrder("Clara Cram", "Hans Müller");
}
@Test
void parseReceivers_partAlreadyFullName_notDistributed_fromLastSegmentLastName() {
// "Clara Cram und Eugenie de Gruyter": first part has its own name, no distribution
List<String> result = PersonNameParser.parseReceivers("Clara Cram und Eugenie de Gruyter");
assertThat(result).containsExactlyInAnyOrder("Clara Cram", "Eugenie de Gruyter");
}
@Test
void parseReceivers_returnsEmpty_whenAllPartsAreFamilie() {
// All parts filtered out → nameParts.isEmpty() = true → return List.of()
assertThat(PersonNameParser.parseReceivers("Familie und Familie")).isEmpty();
}
@Test
void parseReceivers_singleTokenKnownLastName_notDistributed() {
// "Müller und Herbert de Gruyter":
// last segment = "Herbert de Gruyter" → detectedLastName = "de Gruyter"
// "Müller": !contains(" ") = true BUT findKnownLastName("Müller") != null → else branch → kept as-is
List<String> result = PersonNameParser.parseReceivers("Müller und Herbert de Gruyter");
assertThat(result).containsExactlyInAnyOrder("Müller", "Herbert de Gruyter");
}
}

View File

@@ -5,9 +5,7 @@ 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.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.web.server.ResponseStatusException;
@@ -35,8 +33,8 @@ class PersonServiceTest {
when(personRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.getById(id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
@@ -49,126 +47,6 @@ class PersonServiceTest {
assertThat(personService.getById(id)).isEqualTo(person);
}
// ─── findAll ─────────────────────────────────────────────────────────────
@Test
void findAll_returnsAll_whenQueryIsNull() {
List<PersonSummaryDTO> expected = List.of();
when(personRepository.findAllWithDocumentCount()).thenReturn(expected);
assertThat(personService.findAll(null)).isEqualTo(expected);
verify(personRepository).findAllWithDocumentCount();
verify(personRepository, never()).searchWithDocumentCount(any());
}
@Test
void findAll_returnsEmpty_whenQueryIsWhitespaceOnly() {
assertThat(personService.findAll(" ")).isEmpty();
verify(personRepository, never()).findAllWithDocumentCount();
verify(personRepository, never()).searchWithDocumentCount(any());
}
@Test
void findAll_searchesByName_whenQueryIsNonBlank() {
List<PersonSummaryDTO> expected = List.of();
when(personRepository.searchWithDocumentCount("Anna")).thenReturn(expected);
assertThat(personService.findAll("Anna")).isEqualTo(expected);
verify(personRepository).searchWithDocumentCount("Anna");
verify(personRepository, never()).findAllWithDocumentCount();
}
// ─── createPerson ─────────────────────────────────────────────────────────
@Test
void createPerson_savesPersonWithNullAlias_whenAliasIsNull() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.createPerson("Hans", "Müller", null);
assertThat(result.getAlias()).isNull();
verify(personRepository).save(argThat(p -> p.getFirstName().equals("Hans") && p.getAlias() == null));
}
@Test
void createPerson_savesPersonWithNullAlias_whenAliasIsBlank() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.createPerson("Hans", "Müller", " ");
assertThat(result.getAlias()).isNull();
}
@Test
void createPerson_savesTrimmedAlias_whenAliasIsNonBlank() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.createPerson("Hans", "Müller", " Hans Müller ");
assertThat(result.getAlias()).isEqualTo("Hans Müller");
}
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
@Test
void createPerson_dto_persistsAllSixFields() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria");
dto.setBirthYear(1901); dto.setDeathYear(1975); dto.setNotes("Some notes");
Person result = personService.createPerson(dto);
assertThat(result.getFirstName()).isEqualTo("Maria");
assertThat(result.getLastName()).isEqualTo("Raddatz");
assertThat(result.getAlias()).isEqualTo("Oma Maria");
assertThat(result.getBirthYear()).isEqualTo(1901);
assertThat(result.getDeathYear()).isEqualTo(1975);
assertThat(result.getNotes()).isEqualTo("Some notes");
}
@Test
void createPerson_dto_yearValidationFires_whenBirthYearNegative() {
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setBirthYear(-1);
assertThatThrownBy(() -> personService.createPerson(dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
// ─── updatePerson (alias) ─────────────────────────────────────────────────
@Test
void updatePerson_setsNullAlias_whenAliasIsBlank() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").alias("old alias").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.setAlias(" ");
Person result = personService.updatePerson(id, dto);
assertThat(result.getAlias()).isNull();
}
@Test
void updatePerson_setsTrimmedAlias_whenAliasIsNonBlank() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").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.setAlias(" Anna Alt ");
Person result = personService.updatePerson(id, dto);
assertThat(result.getAlias()).isEqualTo("Anna Alt");
}
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
@Test
@@ -266,22 +144,6 @@ class PersonServiceTest {
.isEqualTo(400);
}
@Test
void updatePerson_doesNotThrow_whenBirthYearNonNullButDeathYearIsNull() {
// Covers A && B short-circuit: birthYear != null (true) but deathYear == null (false) → no throw
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").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.setBirthYear(1890); dto.setDeathYear(null);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthYear()).isEqualTo(1890);
assertThat(result.getDeathYear()).isNull();
}
@Test
void updatePerson_allowsSameYear() {
UUID id = UUID.randomUUID();
@@ -297,56 +159,6 @@ class PersonServiceTest {
assertThat(result.getDeathYear()).isEqualTo(1900);
}
// ─── Phase 1.3: Year range bounds (> 0) ──────────────────────────────────
@Test
void updatePerson_throwsBadRequest_whenBirthYearIsZero() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(0);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsBadRequest_whenBirthYearIsNegative() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(-5);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsBadRequest_whenDeathYearIsZero() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(0);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsBadRequest_whenDeathYearIsNegative() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(-10);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
// ─── findCorrespondents ──────────────────────────────────────────────────
@Test
@@ -401,8 +213,8 @@ class PersonServiceTest {
when(personRepository.findById(sourceId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}
@@ -415,8 +227,8 @@ class PersonServiceTest {
when(personRepository.findById(targetId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(404);
}

View File

@@ -1,47 +0,0 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
class SseEmitterRegistryTest {
private final SseEmitterRegistry registry = new SseEmitterRegistry();
@Test
void register_returnsEmitter() {
SseEmitter emitter = registry.register(UUID.randomUUID());
assertThat(emitter).isNotNull();
}
@Test
void send_doesNothing_whenNoEmitterRegistered() {
assertThatCode(() -> registry.send(UUID.randomUUID(), "data"))
.doesNotThrowAnyException();
}
@Test
void register_replacesExistingEmitter_forSameUser() {
UUID userId = UUID.randomUUID();
SseEmitter first = registry.register(userId);
SseEmitter second = registry.register(userId);
assertThat(first).isNotSameAs(second);
}
@Test
void send_doesNotThrow_whenEmitterRegistered_andSendFails() {
// Registering an emitter without an active HTTP connection causes IOException on send
UUID userId = UUID.randomUUID();
registry.register(userId);
// Must not propagate the IOException — it's caught and the emitter is removed
assertThatCode(() -> registry.send(userId, "data")).doesNotThrowAnyException();
}
}

View File

@@ -1,67 +0,0 @@
package org.raddatz.familienarchiv.service;
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.raddatz.familienarchiv.repository.AppUserRepository;
import org.springframework.data.domain.PageRequest;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UserSearchServiceTest {
@Mock AppUserRepository userRepository;
@InjectMocks UserSearchService userSearchService;
// ─── search ───────────────────────────────────────────────────────────────
@Test
void search_returnsEmpty_whenQueryIsNull() {
List<AppUser> result = userSearchService.search(null);
assertThat(result).isEmpty();
verify(userRepository, never()).searchByNameOrUsername(any(), any());
}
@Test
void search_returnsEmpty_whenQueryIsBlank() {
List<AppUser> result = userSearchService.search(" ");
assertThat(result).isEmpty();
verify(userRepository, never()).searchByNameOrUsername(any(), any());
}
@Test
void search_delegatesToRepository_whenQueryIsNonBlank() {
AppUser user = AppUser.builder().id(UUID.randomUUID()).username("hans").build();
when(userRepository.searchByNameOrUsername(eq("hans"), any(PageRequest.class)))
.thenReturn(List.of(user));
List<AppUser> result = userSearchService.search("hans");
assertThat(result).containsExactly(user);
verify(userRepository).searchByNameOrUsername(eq("hans"), any(PageRequest.class));
}
@Test
void search_trimsQuery_beforeDelegating() {
when(userRepository.searchByNameOrUsername(eq("hans"), any(PageRequest.class)))
.thenReturn(List.of());
userSearchService.search(" hans ");
verify(userRepository).searchByNameOrUsername(eq("hans"), any(PageRequest.class));
}
}

View File

@@ -5,20 +5,17 @@ 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.dto.AdminUpdateUserRequest;
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.UserGroupRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -219,78 +216,6 @@ class UserServiceTest {
verify(userRepository).save(argThat(u -> "newHash".equals(u.getPassword())));
}
// ─── adminUpdateUser ──────────────────────────────────────────────────────
@Test
void adminUpdateUser_updatesNameFields() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getFirstName()).isEqualTo("Ada");
assertThat(result.getLastName()).isEqualTo("Lovelace");
}
@Test
void adminUpdateUser_preservesGroups_whenGroupIdsIsNull() {
UUID id = UUID.randomUUID();
UserGroup adminGroup = UserGroup.builder().id(UUID.randomUUID()).name("Administrators").build();
AppUser user = AppUser.builder().id(id).username("admin").groups(Set.of(adminGroup)).build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setFirstName("Ada"); // groupIds left null → don't change groups
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getGroups()).containsExactly(adminGroup);
}
@Test
void adminUpdateUser_updatesGroups_whenGroupIdsProvided() {
UUID id = UUID.randomUUID();
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").build();
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").build();
AppUser user = AppUser.builder().id(id).username("max").groups(Set.of(oldGroup)).build();
when(userRepository.findById(id)).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()));
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getGroups()).containsExactly(newGroup);
}
@Test
void adminUpdateUser_clearsGroups_whenGroupIdsIsEmptyList() {
// Sending groupIds:[] is the explicit "remove from all groups" signal.
// The frontend must NEVER send [] accidentally — it must always include
// the currently-selected group checkboxes.
UUID id = UUID.randomUUID();
UserGroup adminGroup = UserGroup.builder().id(UUID.randomUUID()).name("Administrators").build();
AppUser user = AppUser.builder().id(id).username("admin").groups(Set.of(adminGroup)).build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(groupRepository.findAllById(List.of())).thenReturn(List.of());
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setGroupIds(List.of()); // empty list → intentional "remove all groups"
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getGroups()).isEmpty();
}
// ─── getGroupById ─────────────────────────────────────────────────────────
@Test
@@ -301,378 +226,4 @@ class UserServiceTest {
assertThatThrownBy(() -> userService.getGroupById(id))
.isInstanceOf(DomainException.class);
}
// ─── createUserOrUpdate — groups loaded ───────────────────────────────────
@Test
void createUserOrUpdate_loadsGroups_whenGroupIdsNonEmpty() {
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
CreateUserRequest req = new CreateUserRequest();
req.setUsername("newuser");
req.setEmail("u@example.com");
req.setInitialPassword("pass");
req.setGroupIds(List.of(group.getId()));
when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
when(passwordEncoder.encode("pass")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
when(userRepository.save(any())).thenReturn(saved);
AppUser result = userService.createUserOrUpdate(req);
assertThat(result).isEqualTo(saved);
verify(groupRepository).findAllById(List.of(group.getId()));
}
// ─── updateProfile — email edge cases ─────────────────────────────────────
@Test
void updateProfile_setsEmailToNull_whenEmailIsBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").email("old@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setEmail(" "); // blank — should clear email
AppUser result = userService.updateProfile(id, dto);
assertThat(result.getEmail()).isNull();
}
@Test
void updateProfile_doesNotChangeEmail_whenEmailDtoIsNull() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").email("keep@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setEmail(null); // null — no change
AppUser result = userService.updateProfile(id, dto);
assertThat(result.getEmail()).isEqualTo("keep@example.com");
}
@Test
void updateProfile_setsContactToNull_whenContactIsBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setContact(" ");
AppUser result = userService.updateProfile(id, dto);
assertThat(result.getContact()).isNull();
}
// ─── adminUpdateUser — password and email branches ────────────────────────
@Test
void adminUpdateUser_setsPassword_whenNewPasswordProvided() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").password("old").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(passwordEncoder.encode("newSecret")).thenReturn("newHashed");
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword("newSecret");
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getPassword()).isEqualTo("newHashed");
}
@Test
void adminUpdateUser_doesNotChangePassword_whenNewPasswordIsBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").password("original").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setNewPassword(" ");
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getPassword()).isEqualTo("original");
verify(passwordEncoder, never()).encode(any());
}
@Test
void adminUpdateUser_setsEmailToNull_whenEmailIsBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").email("old@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(" ");
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getEmail()).isNull();
}
@Test
void adminUpdateUser_throwsConflict_whenEmailTakenByAnotherUser() {
UUID id = UUID.randomUUID();
UUID otherId = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").build();
AppUser other = AppUser.builder().id(otherId).username("anna").email("taken@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("taken@example.com")).thenReturn(Optional.of(other));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("taken@example.com");
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("E-Mail");
}
// ─── updateGroup ──────────────────────────────────────────────────────────
@Test
void updateGroup_updatesNameAndPermissions_whenBothProvided() {
UUID id = UUID.randomUUID();
UserGroup group = UserGroup.builder().id(id).name("OldName")
.permissions(Set.of("READ_ALL")).build();
when(groupRepository.findById(id)).thenReturn(Optional.of(group));
when(groupRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
dto.setName("NewName");
dto.setPermissions(Set.of("WRITE_ALL"));
UserGroup result = userService.updateGroup(id, dto);
assertThat(result.getName()).isEqualTo("NewName");
assertThat(result.getPermissions()).containsExactly("WRITE_ALL");
}
@Test
void updateGroup_keepsExistingName_whenNameIsNull() {
UUID id = UUID.randomUUID();
UserGroup group = UserGroup.builder().id(id).name("Existing").build();
when(groupRepository.findById(id)).thenReturn(Optional.of(group));
when(groupRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
dto.setName(null);
dto.setPermissions(Set.of("ADMIN"));
UserGroup result = userService.updateGroup(id, dto);
assertThat(result.getName()).isEqualTo("Existing");
}
@Test
void updateGroup_keepsExistingPermissions_whenPermissionsAreNull() {
UUID id = UUID.randomUUID();
UserGroup group = UserGroup.builder().id(id).name("Group")
.permissions(Set.of("READ_ALL")).build();
when(groupRepository.findById(id)).thenReturn(Optional.of(group));
when(groupRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
dto.setName("NewName");
dto.setPermissions(null);
UserGroup result = userService.updateGroup(id, dto);
assertThat(result.getPermissions()).containsExactly("READ_ALL");
}
// ─── createUserOrUpdate — empty groupIds ──────────────────────────────────
@Test
void createUserOrUpdate_doesNotLoadGroups_whenGroupIdsIsEmpty() {
CreateUserRequest req = new CreateUserRequest();
req.setUsername("newuser");
req.setEmail("u@example.com");
req.setInitialPassword("pass");
req.setGroupIds(List.of()); // empty, not null
when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
when(passwordEncoder.encode("pass")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(req);
verify(groupRepository, never()).findAllById(any());
}
// ─── updateProfile — contact null ─────────────────────────────────────────
@Test
void updateProfile_setsTrimmedContact_whenContactIsNonBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setContact(" phone: 999 ");
AppUser result = userService.updateProfile(id, dto);
assertThat(result.getContact()).isEqualTo("phone: 999");
}
@Test
void updateProfile_setsNullContact_whenContactIsNull() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").contact("old contact").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setContact(null);
AppUser result = userService.updateProfile(id, dto);
assertThat(result.getContact()).isNull();
}
@Test
void updateProfile_allowsSameEmail_whenEmailBelongsToSameUser() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("max").email("me@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("me@example.com")).thenReturn(Optional.of(user)); // same user
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
UpdateProfileDTO dto = new UpdateProfileDTO();
dto.setEmail("me@example.com");
// Must not throw
AppUser result = userService.updateProfile(id, dto);
assertThat(result.getEmail()).isEqualTo("me@example.com");
}
// ─── adminUpdateUser — contact null and email null ────────────────────────
@Test
void adminUpdateUser_setsNullContact_whenContactIsNull() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").contact("old contact").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(null);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getContact()).isNull();
}
@Test
void adminUpdateUser_setsNullContact_whenContactIsBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").contact("old").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" ");
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getContact()).isNull();
}
@Test
void adminUpdateUser_setsTrimmedContact_whenContactIsNonBlank() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setContact(" phone: 555 ");
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getContact()).isEqualTo("phone: 555");
}
@Test
void adminUpdateUser_doesNotModifyEmail_whenEmailIsNull() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").email("keep@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail(null);
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getEmail()).isEqualTo("keep@example.com");
}
@Test
void adminUpdateUser_allowsSameEmail_whenEmailBelongsToSameUser() {
UUID id = UUID.randomUUID();
AppUser user = AppUser.builder().id(id).username("admin").email("me@example.com").build();
when(userRepository.findById(id)).thenReturn(Optional.of(user));
when(userRepository.findByEmail("me@example.com")).thenReturn(Optional.of(user)); // same user
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
dto.setEmail("me@example.com");
// Must not throw
AppUser result = userService.adminUpdateUser(id, dto);
assertThat(result.getEmail()).isEqualTo("me@example.com");
}
// ─── createUserOrUpdate — null groupIds ──────────────────────────────────
@Test
void createUserOrUpdate_doesNotLoadGroups_whenGroupIdsIsNull() {
// request.getGroupIds() == null → short-circuit (A=false), groupRepository never called
CreateUserRequest req = new CreateUserRequest();
req.setUsername("nullgroups");
req.setEmail("ng@example.com");
req.setInitialPassword("pass");
req.setGroupIds(null); // null → first condition false → short-circuit
when(userRepository.findByUsername("nullgroups")).thenReturn(Optional.empty());
when(passwordEncoder.encode("pass")).thenReturn("encoded");
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("nullgroups").build();
when(userRepository.save(any())).thenReturn(saved);
userService.createUserOrUpdate(req);
verify(groupRepository, never()).findAllById(any());
}
// ─── createGroup ──────────────────────────────────────────────────────────
@Test
void createGroup_createsGroupWithNameAndPermissions() {
org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
dto.setName("Familie");
dto.setPermissions(Set.of("READ_ALL", "WRITE_ALL"));
UserGroup saved = UserGroup.builder().id(UUID.randomUUID()).name("Familie")
.permissions(Set.of("READ_ALL", "WRITE_ALL")).build();
when(groupRepository.save(any())).thenReturn(saved);
UserGroup result = userService.createGroup(dto);
assertThat(result.getName()).isEqualTo("Familie");
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
}
}

View File

@@ -1,15 +0,0 @@
app:
s3:
endpoint: http://localhost:9000
access-key: dummy
secret-key: dummy
bucket: test-bucket
region: us-east-1
spring:
datasource:
url: will-be-overridden-by-testcontainers
username: test
password: test
mail:
host: localhost

View File

@@ -98,7 +98,6 @@ services:
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
S3_REGION: us-east-1
SPRING_PROFILES_ACTIVE: dev,e2e
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
# Defaults to the local Mailpit catcher — override in .env for production SMTP
MAIL_HOST: ${MAIL_HOST:-mailpit}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,855 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Header / Navigation Redesign Spec · Familienarchiv</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5}
.doc{max-width:1440px;margin:0 auto;padding:48px 32px}
/* ── Masthead ─── */
.mast{background:#0D2240;border-radius:10px;padding:32px 40px;margin-bottom:48px}
.mast-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:16px}
.mast h1{font-size:22px;font-weight:900;color:#fff;letter-spacing:-.4px;margin-bottom:6px}
.mast p{font-size:12px;color:rgba(255,255,255,.5);max-width:620px;line-height:1.7}
.mast-badge{font-size:9px;font-weight:800;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap;flex-shrink:0;margin-top:4px}
.mb-draft{background:#FCD34D;color:#78350F}
.decisions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.1);padding-top:16px}
.dec{background:rgba(255,255,255,.06);border-radius:6px;padding:10px 12px}
.dec-label{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.35);margin-bottom:5px}
.dec-value{font-size:9.5px;font-weight:700;color:#fff;line-height:1.5}
.dec-value s{color:rgba(255,255,255,.3);font-weight:400}
/* ── Section headings ─── */
.sec{margin-bottom:64px}
.sec+.sec{border-top:2px dashed #C8C4BE;padding-top:56px}
.sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
.sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
.sec-num{background:#0D2240;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
/* ── Screen grid ─── */
.sg{display:grid;gap:20px;align-items:start}
.sg-3{grid-template-columns:1fr 1fr 1fr}
.sg-2{grid-template-columns:1fr 1fr}
.sg-2a{grid-template-columns:1.3fr 1fr}
.sg-mob{grid-template-columns:1fr 220px}
.sb{display:flex;flex-direction:column}
.sl{font-size:9px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
.sz{background:#E8E4DF;color:#666;padding:1px 5px;border-radius:3px;font-size:8px}
.state{padding:1px 6px;border-radius:3px;font-size:8px;font-weight:700}
.st-bad{background:#FEE2E2;color:#991B1B}
.st-good{background:#DCFCE7;color:#166534}
.st-warn{background:#FEF3C7;color:#92400E}
.sc{font-size:8.5px;color:#888;margin-top:6px;font-style:italic;line-height:1.5}
/* ── Annotation callouts ─── */
.ann{display:inline-block;font-size:7.5px;font-weight:700;color:#C2410C;background:#FFF7ED;border:1px solid #FDBA74;border-radius:3px;padding:1px 5px;white-space:nowrap}
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:8px 10px;font-size:10px;color:#7C2D12;line-height:1.5;margin-top:10px}
.ann-block strong{font-weight:800}
.ann-block ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.ann-block ol{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
/* ── Wireframe Chrome ─── */
.wf{background:#fff;border:2px solid #B8B4AE;border-radius:10px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08)}
.wf-bar{height:24px;background:#E8E4DF;border-bottom:1px solid #C8C4BE;display:flex;align-items:center;padding:0 9px;gap:4px}
.dot{width:7px;height:7px;border-radius:50%;background:#C8C4BE}
.dot.r{background:#F87171}.dot.y{background:#FCD34D}.dot.g{background:#4ADE80}
.urlbar{flex:1;height:11px;background:#D8D4CE;border-radius:3px;margin-left:6px;display:flex;align-items:center;padding:0 5px}
.urlbar span{font-size:7.5px;color:#888;font-family:monospace}
/* ── Current header (white/broken) ─── */
.H-OLD{height:44px;background:#ffffff;border-bottom:1.5px solid #E5E7EB;display:flex;align-items:center;padding:0 16px;gap:14px;position:relative}
.H-OLD-LOGO{font-size:9px;font-weight:900;color:#012851;letter-spacing:1.2px;font-family:'Arial Black',sans-serif}
.H-OLD-NAV{display:flex;gap:10px;align-items:center;margin-left:8px}
.H-OLD-LINK{font-size:7.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#012851;padding:3px 7px;border-radius:4px}
.H-OLD-LINK.act{background:rgba(180,185,255,0.15);color:#012851}
.H-OLD-R{margin-left:auto;display:flex;gap:7px;align-items:center}
.H-OLD-ICO{width:22px;height:22px;background:#F3F4F6;border-radius:4px;border:1px solid #E5E7EB}
.H-OLD-AV{width:22px;height:22px;background:#012851;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:900;color:#fff}
/* ── NEW header atoms ─── */
.STRIP{height:4px;background:#B4B9FF} /* brand-purple accent strip */
.N{height:42px;background:#012851;display:flex;align-items:center;padding:0 16px;gap:14px;flex-shrink:0}
.logo{font-size:9px;font-weight:900;color:#fff;letter-spacing:1.2px;font-family:'Arial Black',sans-serif}
.nl{font-size:7.5px;color:rgba(255,255,255,.55);font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding-bottom:2px}
.nl:hover{color:rgba(255,255,255,.85)}
.nl.on{color:#fff;border-bottom:2px solid #A1DCD8;padding-bottom:2px}
.nr{margin-left:auto;display:flex;gap:8px;align-items:center}
.nico{width:20px;height:20px;background:rgba(255,255,255,.1);border-radius:4px}
.nico-av{width:22px;height:22px;background:#A1DCD8;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:900;color:#012851}
.nico-lbl{font-size:7px;color:rgba(255,255,255,.6);font-weight:700;text-transform:uppercase}
/* ── Page body placeholder ─── */
.MAIN{padding:14px 18px;display:flex;flex-direction:column;gap:10px;background:#ECEAE4;min-height:80px}
.PH{height:7px;background:#D8D4CE;border-radius:2px;margin-bottom:4px}
.w80{width:80%}.w70{width:70%}.w60{width:60%}.w50{width:50%}.w40{width:40%}.w30{width:30%}
/* ── Mobile chrome ─── */
.WF-M{background:#fff;border:2px solid #B8B4AE;border-radius:16px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08);width:220px}
.WF-M-STATUS{height:18px;background:#012851;display:flex;align-items:center;justify-content:space-between;padding:0 10px}
.WF-M-TIME{font-size:7px;color:#fff;font-weight:700}
.WF-M-ICONS{display:flex;gap:3px}
.WF-M-ICON{width:6px;height:6px;background:rgba(255,255,255,.5);border-radius:1px}
.N-M{height:42px;display:flex;align-items:center;padding:0 12px;justify-content:space-between}
.HAMBURGER{display:flex;flex-direction:column;gap:3px;justify-content:center;width:18px}
.HAMBURGER-LINE{height:1.5px;background:rgba(255,255,255,.85);border-radius:1px}
/* Mobile old (white bg) */
.N-M-OLD{height:44px;background:#ffffff;border-bottom:1.5px solid #E5E7EB;display:flex;align-items:center;padding:0 12px;justify-content:space-between}
.H-OLD-M-LOGO{font-size:9px;font-weight:900;color:#012851;letter-spacing:1.2px}
/* Nav drawer */
.DRAWER{background:#fff;border-top:1px solid #E5E7EB;padding:10px 0}
.DRAWER-LINK{display:flex;align-items:center;padding:8px 14px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#012851;border-left:3px solid transparent}
.DRAWER-LINK.on{border-left-color:#A1DCD8;background:#F0EFE9;color:#012851}
.DRAWER-LINK.off{color:rgba(1,40,81,.55)}
.DRAWER-DIV{height:1px;background:#E5E7EB;margin:6px 14px}
.DRAWER-LANG{display:flex;align-items:center;gap:8px;padding:6px 14px}
.DRAWER-LANG-BTN{font-size:7.5px;font-weight:700;color:#012851;padding:2px 7px;border-radius:3px;border:1.5px solid #D1D5DB}
.DRAWER-LANG-BTN.on{border-color:#012851;background:#012851;color:#fff}
/* ── Nav state grid ─── */
.NSG{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
.NS{display:flex;flex-direction:column;gap:6px}
.NS-LABEL{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
.NS-DEMO{height:40px;background:#012851;display:flex;align-items:center;padding:0 14px;border-radius:6px}
.NS-LINK{font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding-bottom:2px}
.NS-INACTIVE{color:rgba(255,255,255,.55)}
.NS-HOVER{color:rgba(255,255,255,.85)}
.NS-ACTIVE{color:#fff;border-bottom:2px solid #A1DCD8}
.NS-FOCUS{color:#fff;outline:2px solid #A1DCD8;outline-offset:3px;border-radius:2px;padding:1px 3px}
/* ── Contrast badge ─── */
.contrast-badge{display:inline-flex;align-items:center;gap:4px;font-size:8px;font-weight:700;padding:2px 7px;border-radius:20px}
.cr-fail{background:#FEE2E2;color:#991B1B}
.cr-pass{background:#DCFCE7;color:#166534}
/* ── Login page ─── */
.LOGIN-BG{background:#F0EFE9;min-height:120px;display:flex;flex-direction:column;align-items:center;padding-bottom:14px}
.LOGIN-HEADER{width:100%;height:4px;background:#B4B9FF}
.LOGIN-NAV{width:100%;height:42px;background:#012851;display:flex;align-items:center;justify-content:space-between;padding:0 16px}
.LOGIN-CARD{background:#fff;border:1.5px solid #D8D4CE;border-radius:8px;padding:14px 18px;width:200px;margin-top:14px}
.LOGIN-CARD-TITLE{font-size:10px;font-weight:900;color:#012851;margin-bottom:10px;letter-spacing:-.2px}
.LOGIN-FIELD{margin-bottom:7px}
.LOGIN-FIELD-L{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:3px}
.LOGIN-FIELD-I{height:26px;border:1.5px solid #D1D5DB;border-radius:3px;background:#fff;width:100%}
.LOGIN-BTN{height:28px;background:#012851;border-radius:3px;width:100%;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:800;color:#fff;text-transform:uppercase;letter-spacing:.5px;margin-top:10px}
/* ── Changelog / decision list ─── */
.CHANGES{background:#fff;border:1.5px solid #E0DDD6;border-radius:8px;padding:20px 24px;margin-bottom:40px}
.CHANGES h2{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid #E8E4DF}
.CHANGES-GRID{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.C-COL h3{font-size:10px;font-weight:800;color:#444;margin-bottom:8px}
.C-COL ul{list-style:none;display:flex;flex-direction:column;gap:5px}
.C-COL ul li{font-size:11px;color:#555;padding-left:16px;position:relative;line-height:1.5}
.C-COL.new li::before{content:'✦';position:absolute;left:0;color:#012851;font-size:8px}
.C-COL.remove li::before{content:'✗';position:absolute;left:0;color:#DC2626}
.C-COL.keep li::before{content:'→';position:absolute;left:0;color:#888}
/* ── Impl notes ─── */
.IMPL{background:#0D2240;border-radius:8px;padding:20px 24px;margin-top:48px}
.IMPL h2{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.4);margin-bottom:16px;padding-bottom:10px;border-bottom:1px solid rgba(255,255,255,.08)}
.IMPL-GRID{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
.IMPL-COL h3{font-size:9.5px;font-weight:800;color:rgba(255,255,255,.6);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}
.IMPL-COL ul{list-style:none;display:flex;flex-direction:column;gap:5px}
.IMPL-COL ul li{font-size:10.5px;color:rgba(255,255,255,.75);padding-left:14px;position:relative;line-height:1.5}
.IMPL-COL ul li::before{content:'';position:absolute;left:0;color:rgba(255,255,255,.3)}
.IMPL-COL code{font-family:monospace;font-size:9.5px;background:rgba(255,255,255,.08);padding:1px 4px;border-radius:3px;color:#A1DCD8}
/* ── Dark mode simulation ─── */
.DK .N{background:#012851} /* same! brand constant */
.DK .nl{color:rgba(255,255,255,.55)}
.DK .nl.on{color:#fff}
.DK-MAIN{background:#1A1A1A;padding:14px 18px;min-height:60px}
.DK-PH{height:7px;background:#2A2A2A;border-radius:2px;margin-bottom:4px}
/* ── Measurement annotation ─── */
.MEA{display:flex;align-items:center;gap:4px;font-size:7.5px;font-weight:700;color:#6B7280;margin-top:8px}
.MEA-LINE{flex:1;height:1px;border-top:1px dashed #C8C4BE}
.MEA-VAL{background:#E8E4DF;padding:1px 6px;border-radius:3px;white-space:nowrap}
.token{font-family:monospace;font-size:8.5px;background:#F0EFE9;border:1px solid #D8D4CE;padding:1px 5px;border-radius:3px;color:#012851}
/* ── Color swatch ─── */
.SW{display:flex;flex-direction:column;align-items:flex-start;gap:3px}
.SW-BOX{width:36px;height:20px;border-radius:3px;border:1px solid rgba(0,0,0,.1)}
.SW-NAME{font-size:7.5px;font-weight:700;color:#444}
.SW-HEX{font-size:7px;color:#888;font-family:monospace}
</style>
</head>
<body>
<div class="doc">
<!-- ══════════════════════════════════
MASTHEAD
══════════════════════════════════ -->
<div class="mast">
<div class="mast-top">
<div>
<h1>Header / Navigation Redesign</h1>
<p>Full header redesign: brand-navy bar, 4px purple accent strip, always-visible logo on mobile, high-contrast nav states, dark-mode as brand constant, and integrated login header. Replaces the current white <code style="font-family:monospace;font-size:10px;background:rgba(255,255,255,.08);padding:1px 4px;border-radius:3px;color:#A1DCD8">bg-surface</code> header that leaks the semantic surface color into what should be a brand-constant element.</p>
</div>
<span class="mast-badge mb-draft">Draft · 2026-03-30</span>
</div>
<div style="font-size:8.5px;color:rgba(255,255,255,.3);margin-bottom:12px">Leonie Voss · Senior UX Designer</div>
<div class="decisions">
<div class="dec">
<div class="dec-label">Header background</div>
<div class="dec-value"><s>bg-surface (#fff)</s><br>→ brand-navy #012851</div>
</div>
<div class="dec">
<div class="dec-label">Top accent strip</div>
<div class="dec-value"><s>None</s><br>→ 4px · brand-purple #B4B9FF</div>
</div>
<div class="dec">
<div class="dec-label">Active nav state</div>
<div class="dec-value"><s>rgba purple pill (~1.08:1)</s><br>→ white + mint underline</div>
</div>
<div class="dec">
<div class="dec-label">Mobile logo</div>
<div class="dec-value"><s>Hidden</s><br>→ Always visible, left side</div>
</div>
<div class="dec">
<div class="dec-label">Dark mode header</div>
<div class="dec-value"><s>Flips to #1a1a1a</s><br>→ Stays brand-navy (constant)</div>
</div>
<div class="dec">
<div class="dec-label">Login page header</div>
<div class="dec-value"><s>Hidden entirely</s><br>→ Brand header, logo-only</div>
</div>
<div class="dec">
<div class="dec-label">Language switcher (login)</div>
<div class="dec-value"><s>Floating, no context</s><br>→ Integrated in login header right</div>
</div>
<div class="dec">
<div class="dec-label">Total header height</div>
<div class="dec-value">4px strip + 64px bar<br>= 68px total</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════
CHANGES SUMMARY
══════════════════════════════════ -->
<div class="CHANGES">
<h2>What changes vs. current implementation</h2>
<div class="CHANGES-GRID">
<div class="C-COL new">
<h3>New / changed</h3>
<ul>
<li>Header <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">bg-surface</code> → fixed <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">bg-brand-navy</code> (#012851) — not theme-aware</li>
<li>4px accent strip above header: <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">background: #B4B9FF</code></li>
<li>Nav link colors on navy: inactive 55% white, hover 85% white, active 100% white</li>
<li>Active indicator: 2px bottom border in brand-mint (#A1DCD8) instead of rgba purple pill</li>
<li>Mobile: logo always visible left; hamburger icon white (was hidden or missing)</li>
<li>User avatar: mint background (#A1DCD8) with navy text (#012851)</li>
<li>Dark mode: <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">dark:bg-surface</code> override removed from header — stays navy</li>
<li>Login page: <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">isAuthPage</code> guard changed — shows logo-only header, not <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">null</code></li>
<li>Language switcher on login: moved into header right slot</li>
<li>Mobile drawer: opens below navy header, white background, navy text links, mint active indicator</li>
</ul>
</div>
<div class="C-COL keep">
<h3>Kept unchanged</h3>
<ul>
<li><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">AppNav</code> component structure — only CSS changes</li>
<li>Sticky header behavior (<code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">sticky top-0 z-50</code>)</li>
<li>Max-width container and horizontal padding</li>
<li>NotificationBell, ThemeToggle, LanguageSwitcher components — only icon color changes</li>
<li>UserMenu component — only avatar color changes</li>
<li>Mobile drawer open/close logic</li>
<li>Admin nav link conditional visibility</li>
<li><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">isAuthPage</code> derived value — still used, just different output</li>
</ul>
</div>
</div>
</div>
<!-- ══════════════════════════════════
SECTION 1 — CURRENT STATE
══════════════════════════════════ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">1</span> Current state — problems annotated</div>
<div class="sg sg-2a">
<div class="sb">
<div class="sl">Desktop <span class="sz">≥768px</span> <span class="state st-bad">6 issues</span></div>
<div class="wf">
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/dokumente</span></div></div>
<!-- Current broken header -->
<div class="H-OLD">
<!-- issue 1: white bg, no strip -->
<div style="position:absolute;top:0;left:0;right:0;height:2px;background:#FDBA74;opacity:.3"></div>
<span class="H-OLD-LOGO">FAMILIENARCHIV</span>
<div class="H-OLD-NAV">
<span class="H-OLD-LINK act">Dokumente</span>
<span class="H-OLD-LINK">Personen</span>
<span class="H-OLD-LINK">Korrespondenz</span>
</div>
<div class="H-OLD-R">
<span style="font-size:7px;color:#6B7280;font-weight:700">DE</span>
<div class="H-OLD-ICO"></div>
<div class="H-OLD-ICO"></div>
<div class="H-OLD-AV">LV</div>
</div>
</div>
<div class="MAIN"><div class="PH w80"></div><div class="PH w60"></div><div class="PH w70"></div></div>
</div>
<div class="ann-block">
<strong>Issues — desktop</strong>
<ol>
<li><strong></strong> Background is <code style="font-size:9px;background:#FFF0E8;padding:1px 3px;border-radius:2px">bg-surface</code> (white) — not brand. Every other archival app in this family uses a dark branded header.</li>
<li><strong></strong> No 4px accent strip at top — missing the canonical brand-purple cap.</li>
<li><strong></strong> Active link "Dokumente" uses <code style="font-size:9px;background:#FFF0E8;padding:1px 3px;border-radius:2px">rgba(180,185,255,0.15)</code> on white = contrast ~1.08:1. Completely invisible. WCAG AA minimum is 3:1 for UI components.</li>
<li><strong></strong> Logo is navy-on-white — works in light mode but will disappear in dark mode if header ever inherits <code style="font-size:9px;background:#FFF0E8;padding:1px 3px;border-radius:2px">#1a1a1a</code>.</li>
<li><strong></strong> Dark mode: header flips to near-black (#1a1a1a) — breaks brand consistency. Header should be a brand constant, not a semantic surface.</li>
<li><strong></strong> User avatar: dark navy circle blends with any dark-mode context and provides no semantic meaning via color.</li>
</ol>
</div>
</div>
<div class="sb">
<div class="sl">Mobile <span class="sz">375px</span> <span class="state st-bad">logo missing</span></div>
<div style="display:flex;justify-content:center">
<div class="WF-M">
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
<div class="N-M-OLD">
<!-- No logo! Just hamburger. -->
<div style="width:18px;display:flex;flex-direction:column;gap:3px">
<div style="height:1.5px;background:#012851;border-radius:1px"></div>
<div style="height:1.5px;background:#012851;border-radius:1px"></div>
<div style="height:1.5px;background:#012851;border-radius:1px"></div>
</div>
<div class="H-OLD-ICO" style="width:20px;height:20px"></div>
<div class="H-OLD-AV">LV</div>
</div>
<div class="MAIN" style="padding:10px 12px;min-height:60px">
<div class="PH w80"></div><div class="PH w60"></div>
</div>
</div>
</div>
<div class="ann-block" style="margin-top:8px">
<strong>Mobile issues</strong>
<ul>
<li>Logo hidden on mobile — brand identity completely lost</li>
<li>Hamburger icon is dark on white — fine in light mode, breaks in dark</li>
<li>Header still white — same surface-color problem as desktop</li>
</ul>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════
SECTION 2 — PROPOSED DESKTOP
══════════════════════════════════ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">2</span> Proposed redesign — Desktop</div>
<div class="sg sg-2" style="margin-bottom:24px">
<!-- Light mode -->
<div class="sb">
<div class="sl">Light mode <span class="sz">≥768px</span> <span class="state st-good">Proposed</span></div>
<div class="wf">
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/korrespondenz</span></div></div>
<div class="STRIP"></div>
<div class="N">
<span class="logo">FAMILIENARCHIV</span>
<div style="width:1px;height:16px;background:rgba(255,255,255,.15);margin:0 2px"></div>
<span class="nl">Dokumente</span>
<span class="nl">Personen</span>
<span class="nl on">Korrespondenz</span>
<div class="nr">
<span class="nico-lbl">DE</span>
<div class="nico" style="background:rgba(255,255,255,.12)"></div><!-- theme toggle -->
<div class="nico" style="background:rgba(255,255,255,.12)"></div><!-- bell -->
<div class="nico-av">LV</div>
</div>
</div>
<div class="MAIN"><div class="PH w80"></div><div class="PH w60"></div><div class="PH w70"></div></div>
</div>
<div class="MEA">
<div class="MEA-LINE"></div>
<span class="MEA-VAL">4px accent strip · background: #B4B9FF</span>
<div class="MEA-LINE"></div>
</div>
<div class="MEA">
<div class="MEA-LINE"></div>
<span class="MEA-VAL">64px nav bar · background: #012851</span>
<div class="MEA-LINE"></div>
</div>
<div class="MEA">
<div class="MEA-LINE"></div>
<span class="MEA-VAL">Total: 68px</span>
<div class="MEA-LINE"></div>
</div>
<div class="sc">Active link: "Korrespondenz" — white text + 2px bottom border in #A1DCD8. Divider between logo and nav: rgba(255,255,255,0.15). Avatar: mint bg + navy text.</div>
</div>
<!-- Dark mode -->
<div class="sb">
<div class="sl">Dark mode <span class="sz">≥768px</span> <span class="state st-good">Same navy — brand constant</span></div>
<div class="wf" style="border-color:#444">
<div class="wf-bar" style="background:#333;border-color:#444"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar" style="background:#444"><span style="color:#aaa">/korrespondenz</span></div></div>
<div class="STRIP"></div><!-- strip is identical — not theme-aware -->
<div class="N"><!-- N stays the same #012851 in dark mode too -->
<span class="logo">FAMILIENARCHIV</span>
<div style="width:1px;height:16px;background:rgba(255,255,255,.15);margin:0 2px"></div>
<span class="nl">Dokumente</span>
<span class="nl">Personen</span>
<span class="nl on">Korrespondenz</span>
<div class="nr">
<span class="nico-lbl">DE</span>
<div class="nico"></div>
<div class="nico"></div>
<div class="nico-av">LV</div>
</div>
</div>
<div class="DK-MAIN"><div class="DK-PH w80"></div><div class="DK-PH w60"></div><div class="DK-PH w70"></div></div>
</div>
<div class="ann-block" style="background:#EFF6FF;border-color:#BFDBFE;color:#1E40AF;margin-top:8px">
<strong>Dark mode rule:</strong> The header is a brand element, not a semantic surface. It does NOT respond to the <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">dark:</code> variant. Page content behind it switches; the header stays #012851.
<ul style="margin-top:4px">
<li>Remove <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">dark:bg-surface</code> from header element</li>
<li>Apply <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">bg-brand-navy</code> as a non-dark-variant class</li>
</ul>
</div>
</div>
</div>
<!-- Element key -->
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:8px;padding:16px 20px;margin-top:8px">
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:12px">Element color tokens</div>
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:16px">
<div class="SW">
<div class="SW-BOX" style="background:#012851"></div>
<div class="SW-NAME">brand-navy</div>
<div class="SW-HEX">#012851</div>
<div style="font-size:7px;color:#888;margin-top:2px">Header bg</div>
</div>
<div class="SW">
<div class="SW-BOX" style="background:#B4B9FF"></div>
<div class="SW-NAME">brand-purple</div>
<div class="SW-HEX">#B4B9FF</div>
<div style="font-size:7px;color:#888;margin-top:2px">Accent strip</div>
</div>
<div class="SW">
<div class="SW-BOX" style="background:#A1DCD8"></div>
<div class="SW-NAME">brand-mint</div>
<div class="SW-HEX">#A1DCD8</div>
<div style="font-size:7px;color:#888;margin-top:2px">Active underline · avatar bg</div>
</div>
<div class="SW">
<div class="SW-BOX" style="background:#ffffff;border-color:#D0D0D0"></div>
<div class="SW-NAME">white</div>
<div class="SW-HEX">#ffffff</div>
<div style="font-size:7px;color:#888;margin-top:2px">Active link text · logo</div>
</div>
<div class="SW">
<div class="SW-BOX" style="background:rgba(255,255,255,0.55)"></div>
<div class="SW-NAME">white/55</div>
<div class="SW-HEX">rgba(255,255,255,.55)</div>
<div style="font-size:7px;color:#888;margin-top:2px">Inactive nav links</div>
</div>
<div class="SW">
<div class="SW-BOX" style="background:rgba(255,255,255,0.85)"></div>
<div class="SW-NAME">white/85</div>
<div class="SW-HEX">rgba(255,255,255,.85)</div>
<div style="font-size:7px;color:#888;margin-top:2px">Hover nav links</div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════
SECTION 3 — NAV STATES
══════════════════════════════════ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">3</span> Nav link states</div>
<div class="NSG" style="margin-bottom:20px">
<!-- Inactive -->
<div class="NS">
<div class="NS-LABEL">Inactive</div>
<div class="NS-DEMO">
<span class="NS-LINK NS-INACTIVE">Personen</span>
</div>
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
<div><span class="token">color: rgba(255,255,255,.55)</span></div>
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
<span class="contrast-badge cr-pass">4.9:1 ✓ AA</span>
</div>
</div>
<div class="sc">Intentionally muted — communicates "not here yet" without removing affordance.</div>
</div>
<!-- Hover -->
<div class="NS">
<div class="NS-LABEL">Hover</div>
<div class="NS-DEMO">
<span class="NS-LINK NS-HOVER">Personen</span>
</div>
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
<div><span class="token">color: rgba(255,255,255,.85)</span></div>
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
<span class="contrast-badge cr-pass">7.8:1 ✓ AA</span>
</div>
</div>
<div class="sc">Smooth brightness step on hover. Transition: <code style="font-size:9px">color 150ms ease</code>.</div>
</div>
<!-- Active -->
<div class="NS">
<div class="NS-LABEL">Active (current page)</div>
<div class="NS-DEMO">
<span class="NS-LINK NS-ACTIVE">Korrespondenz</span>
</div>
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
<div><span class="token">color: #ffffff</span></div>
<div><span class="token">border-bottom: 2px solid #A1DCD8</span></div>
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
<span class="contrast-badge cr-pass">21:1 ✓ AAA</span>
</div>
</div>
<div class="sc">Mint underline is the active indicator — not a background pill. Clear, low-weight, distinct from hover.</div>
</div>
<!-- Focus -->
<div class="NS">
<div class="NS-LABEL">Focus (keyboard)</div>
<div class="NS-DEMO">
<span class="NS-LINK NS-FOCUS">Personen</span>
</div>
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
<div><span class="token">outline: 2px solid #A1DCD8</span></div>
<div><span class="token">outline-offset: 3px</span></div>
<div><span class="token">border-radius: 2px</span></div>
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
<span class="contrast-badge cr-pass">3.4:1 ✓ AA</span>
</div>
</div>
<div class="sc">Mint outline on navy — meets WCAG 3:1 focus indicator requirement. Never suppress outline.</div>
</div>
</div>
<!-- Before/After contrast comparison -->
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:8px;padding:16px 20px">
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:12px">Active state contrast — before vs. after</div>
<div class="sg sg-2">
<div>
<div class="sl" style="margin-bottom:8px">Before <span class="state st-bad">Fails WCAG</span></div>
<div style="background:#ffffff;border-radius:5px;padding:10px 14px;border:1.5px solid #E5E7EB;display:inline-flex;align-items:center;gap:0">
<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#012851;background:rgba(180,185,255,0.15);padding:4px 10px;border-radius:4px">Dokumente</span>
</div>
<div style="margin-top:8px;font-size:9px;color:#555">
Navy text (#012851) on rgba(180,185,255,0.15) on white.<br>
Effective background: approx. #F4F4FF.<br>
<span class="contrast-badge cr-fail" style="margin-top:4px">~1.08:1 ✗ Fail</span>
</div>
<div class="sc">The active pill is invisible. Users can't tell which page they're on.</div>
</div>
<div>
<div class="sl" style="margin-bottom:8px">After <span class="state st-good">Passes WCAG AAA</span></div>
<div style="background:#012851;border-radius:5px;padding:10px 14px;display:inline-flex;align-items:center">
<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#fff;border-bottom:2px solid #A1DCD8;padding-bottom:2px">Dokumente</span>
</div>
<div style="margin-top:8px;font-size:9px;color:#555">
White text (#ffffff) on navy (#012851).<br>
Mint underline: #A1DCD8 on navy = 3.1:1 for the indicator itself.<br>
<span class="contrast-badge cr-pass" style="margin-top:4px">21:1 ✓ AAA (text)</span>
</div>
<div class="sc">Unambiguous. The underline echoes brand-mint used elsewhere as an accent.</div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════
SECTION 4 — MOBILE
══════════════════════════════════ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">4</span> Mobile header + nav drawer</div>
<div class="sg sg-3" style="align-items:start">
<!-- Current mobile (broken) -->
<div class="sb">
<div class="sl">Current <span class="state st-bad">No logo</span></div>
<div style="display:flex;justify-content:center">
<div class="WF-M">
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
<div class="N-M-OLD">
<div style="width:18px;display:flex;flex-direction:column;gap:3px">
<div style="height:1.5px;background:#012851;border-radius:1px;width:100%"></div>
<div style="height:1.5px;background:#012851;border-radius:1px;width:100%"></div>
<div style="height:1.5px;background:#012851;border-radius:1px;width:100%"></div>
</div>
<div style="margin-left:auto;display:flex;gap:5px;align-items:center">
<div class="H-OLD-ICO" style="width:18px;height:18px"></div>
<div class="H-OLD-AV" style="width:20px;height:20px">LV</div>
</div>
</div>
<div class="MAIN" style="padding:10px 12px;min-height:60px"><div class="PH w80"></div><div class="PH w60"></div></div>
</div>
</div>
<div class="ann-block" style="margin-top:8px">
<strong>Problem:</strong> No logo. The user has zero brand context. On first load, there is no visual cue that this is Familienarchiv. The hamburger icon color (dark navy) will also break in dark mode.
</div>
</div>
<!-- Proposed mobile header -->
<div class="sb">
<div class="sl">Proposed header <span class="state st-good">Logo visible</span></div>
<div style="display:flex;justify-content:center">
<div class="WF-M">
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
<div style="height:3px;background:#B4B9FF"></div><!-- accent strip, thinner on mobile -->
<div class="N-M" style="background:#012851">
<span class="logo" style="font-size:7.5px;letter-spacing:1px">FAMILIENARCHIV</span>
<div style="display:flex;gap:6px;align-items:center">
<div class="nico-av" style="width:20px;height:20px;font-size:5.5px">LV</div>
<div class="HAMBURGER">
<div class="HAMBURGER-LINE"></div>
<div class="HAMBURGER-LINE"></div>
<div class="HAMBURGER-LINE"></div>
</div>
</div>
</div>
<div class="MAIN" style="padding:10px 12px;min-height:60px"><div class="PH w80"></div><div class="PH w60"></div></div>
</div>
</div>
<div class="sc">Logo always visible left. Avatar + hamburger right. Accent strip is 3px on mobile (saves 1px). Background is brand-navy — no theme variation.</div>
</div>
<!-- Proposed drawer open -->
<div class="sb">
<div class="sl">Nav drawer <span class="state st-good">Open state</span></div>
<div style="display:flex;justify-content:center">
<div class="WF-M">
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
<div style="height:3px;background:#B4B9FF"></div>
<div class="N-M" style="background:#012851">
<span class="logo" style="font-size:7.5px;letter-spacing:1px">FAMILIENARCHIV</span>
<div style="display:flex;gap:6px;align-items:center">
<div class="nico-av" style="width:20px;height:20px;font-size:5.5px">LV</div>
<!-- X icon when drawer open -->
<div style="width:18px;height:18px;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.85);font-size:13px;font-weight:300"></div>
</div>
</div>
<!-- Drawer -->
<div class="DRAWER">
<div class="DRAWER-LINK off">Dokumente</div>
<div class="DRAWER-LINK off">Personen</div>
<div class="DRAWER-LINK on">Korrespondenz</div>
<div class="DRAWER-LINK off" style="font-size:7px;color:rgba(1,40,81,.4)">Admin</div>
<div class="DRAWER-DIV"></div>
<div class="DRAWER-LANG">
<span style="font-size:7px;color:#888;font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin-right:4px">Sprache</span>
<div class="DRAWER-LANG-BTN on">DE</div>
<div class="DRAWER-LANG-BTN">EN</div>
<div class="DRAWER-LANG-BTN">ES</div>
</div>
</div>
<div class="MAIN" style="padding:10px 12px;min-height:40px;opacity:.4"><div class="PH w80"></div></div>
</div>
</div>
<div class="sc">Drawer uses white background with navy text — intentional reversal of the dark header. Active page: mint left border + sand background. Language switcher lives in drawer on mobile (not floating).</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════
SECTION 5 — LOGIN PAGE
══════════════════════════════════ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">5</span> Login page — branded header</div>
<div class="sg sg-2">
<!-- Current login (no header) -->
<div class="sb">
<div class="sl">Current — header hidden <span class="state st-bad">No brand context</span></div>
<div class="wf">
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/login</span></div></div>
<!-- Floating language switcher (no context) -->
<div style="position:relative;min-height:140px;background:#F0EFE9">
<div style="position:absolute;top:8px;right:10px;display:flex;gap:4px">
<span style="font-size:7.5px;font-weight:700;color:#012851;background:#fff;border:1.5px solid #D1D5DB;padding:2px 7px;border-radius:3px">DE</span>
<span style="font-size:7.5px;font-weight:700;color:#888;padding:2px 7px;border-radius:3px">EN</span>
<span style="font-size:7.5px;font-weight:700;color:#888;padding:2px 7px;border-radius:3px">ES</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;padding-top:22px">
<div class="LOGIN-CARD">
<div class="LOGIN-CARD-TITLE">Anmelden</div>
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">E-Mail</div><div class="LOGIN-FIELD-I"></div></div>
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">Passwort</div><div class="LOGIN-FIELD-I"></div></div>
<div class="LOGIN-BTN">Anmelden</div>
</div>
</div>
</div>
</div>
<div class="ann-block">
<strong>Problems:</strong> Header is hidden entirely on auth pages. Language switcher floats top-right with no visual anchor — it's a ghost. Users arrive with zero brand context. The page could be any app.
</div>
</div>
<!-- Proposed login (branded header) -->
<div class="sb">
<div class="sl">Proposed — logo-only header <span class="state st-good">Branded</span></div>
<div class="wf">
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/login</span></div></div>
<div class="LOGIN-BG">
<div class="LOGIN-HEADER"></div><!-- 4px purple strip -->
<div class="LOGIN-NAV">
<span class="logo">FAMILIENARCHIV</span>
<!-- Right: language switcher integrated in header -->
<div style="display:flex;gap:6px;align-items:center">
<span style="font-size:7.5px;font-weight:800;color:#fff;opacity:.9">DE</span>
<span style="font-size:7.5px;font-weight:700;color:rgba(255,255,255,.5)">EN</span>
<span style="font-size:7.5px;font-weight:700;color:rgba(255,255,255,.5)">ES</span>
</div>
</div>
<div class="LOGIN-CARD">
<div class="LOGIN-CARD-TITLE">Anmelden</div>
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">E-Mail</div><div class="LOGIN-FIELD-I"></div></div>
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">Passwort</div><div class="LOGIN-FIELD-I"></div></div>
<div class="LOGIN-BTN">Anmelden</div>
</div>
</div>
</div>
<div class="sc">Accent strip + navy header appears on login. No nav links (user is not authenticated). Language switcher lives in header right slot — same position as desktop, consistent muscle memory. The brand is present from the first moment the user sees the app.</div>
</div>
</div>
<!-- Code change note -->
<div class="ann-block" style="background:#EFF6FF;border-color:#BFDBFE;color:#1E40AF;margin-top:16px">
<strong>Implementation change:</strong> In <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">+layout.svelte</code>, the <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">{#if !isAuthPage}</code> guard currently hides the entire header. Replace with a conditional that renders a <em>login variant</em> of the header (logo + lang switcher, no nav links) when <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">isAuthPage</code> is true. Move the <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">LanguageSwitcher</code> import into the header for the auth variant. Remove the floating <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">LanguageSwitcher</code> from <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">/login/+page.svelte</code>.
</div>
</div>
<!-- ══════════════════════════════════
SECTION 6 — RIGHT UTILITIES DETAIL
══════════════════════════════════ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">6</span> Right utility area — element by element</div>
<div style="background:#012851;border-radius:8px;padding:16px 20px;margin-bottom:16px">
<div style="height:40px;display:flex;align-items:center;gap:10px;justify-content:flex-end">
<!-- Language switcher -->
<div style="display:flex;gap:5px;align-items:center;border-right:1px solid rgba(255,255,255,.15);padding-right:10px">
<span style="font-size:8px;font-weight:800;color:#fff">DE</span>
<span style="font-size:8px;font-weight:700;color:rgba(255,255,255,.5)">EN</span>
<span style="font-size:8px;font-weight:700;color:rgba(255,255,255,.5)">ES</span>
</div>
<!-- Theme toggle -->
<div style="width:24px;height:24px;background:rgba(255,255,255,.1);border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:12px;opacity:.7"></div>
<!-- Notification bell -->
<div style="position:relative;width:24px;height:24px;display:flex;align-items:center;justify-content:center">
<span style="font-size:14px;color:rgba(255,255,255,.75)">🔔</span>
<div style="position:absolute;top:3px;right:2px;width:7px;height:7px;background:#EF4444;border-radius:50%;border:1.5px solid #012851"></div>
</div>
<!-- User avatar -->
<div class="nico-av">LV</div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px">
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">Language switcher</div>
<div style="font-size:9px;color:#444;line-height:1.6">
Active lang: <span class="token">color: #ffffff</span><br>
Inactive lang: <span class="token">color: rgba(255,255,255,.5)</span><br>
Separator from rest: <span class="token">border-right: 1px solid rgba(255,255,255,.15)</span><br>
On login: visible in header right slot
</div>
</div>
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">Theme toggle</div>
<div style="font-size:9px;color:#444;line-height:1.6">
Icon: white at <span class="token">opacity: 0.7</span><br>
Hover: <span class="token">opacity: 1.0</span><br>
Background: <span class="token">rgba(255,255,255,.1)</span><br>
No change to toggle logic — icon color only
</div>
</div>
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">Notification bell</div>
<div style="font-size:9px;color:#444;line-height:1.6">
Icon: white at <span class="token">opacity: 0.75</span><br>
Badge: stays <span class="token">bg-red-500</span> (#EF4444)<br>
Badge border: <span class="token">border: 1.5px solid #012851</span> (halos on navy)<br>
No component logic changes
</div>
</div>
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">User avatar</div>
<div style="font-size:9px;color:#444;line-height:1.6">
Background: <span class="token">#A1DCD8</span> (brand-mint)<br>
Text: <span class="token">#012851</span> (brand-navy)<br>
Contrast: 4.8:1 ✓ AA<br>
Replaces navy bg (dark-on-dark in dark mode)
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════
IMPLEMENTATION NOTES
══════════════════════════════════ -->
<div class="IMPL">
<h2>Implementation notes</h2>
<div class="IMPL-GRID">
<div class="IMPL-COL">
<h3>CSS / Tailwind changes</h3>
<ul>
<li>Header: replace <code>bg-surface</code> with <code>bg-[#012851]</code> (or add a <code>bg-brand-navy</code> utility to <code>layout.css</code>)</li>
<li>Remove <code>border-b border-line-2</code> from header — the accent strip replaces the visual separator</li>
<li>Add a <code>&lt;div class="h-1 bg-[#B4B9FF]"&gt;</code> before the nav bar in <code>+layout.svelte</code></li>
<li>Nav links: replace <code>text-ink</code> + <code>bg-nav-active</code> with opacity-based white utilities: <code>text-white/55</code> inactive, <code>hover:text-white/85</code>, <code>text-white border-b-2 border-[#A1DCD8]</code> active</li>
<li>User avatar: swap <code>bg-brand-navy text-white</code><code>bg-[#A1DCD8] text-[#012851]</code></li>
<li>Notification badge: add <code>border-2 border-[#012851]</code> to badge element</li>
<li>Dark mode: on the <code>&lt;header&gt;</code> element, ensure there is NO <code>dark:</code> variant overriding the background</li>
</ul>
</div>
<div class="IMPL-COL">
<h3>Component changes</h3>
<ul>
<li><code>+layout.svelte</code>: split the <code>{#if !isAuthPage}</code> guard into two branches — full header (authed) vs. login header (logo + lang only)</li>
<li><code>AppNav.svelte</code>: ensure logo is always rendered, not hidden on mobile via <code>hidden sm:flex</code> or similar</li>
<li><code>AppNav.svelte</code>: hamburger button — icon color from dark to <code>text-white/85</code></li>
<li><code>AppNav.svelte</code>: active link class — remove <code>bg-nav-active</code>, add bottom border in mint</li>
<li><code>UserMenu.svelte</code>: avatar background and text color</li>
<li><code>ThemeToggle.svelte</code>: icon fill/stroke → <code>text-white/70</code></li>
<li><code>NotificationBell.svelte</code>: icon color → <code>text-white/75</code></li>
<li><code>/login/+page.svelte</code>: remove standalone <code>&lt;LanguageSwitcher&gt;</code> — it moves to the layout header</li>
</ul>
</div>
<div class="IMPL-COL">
<h3>CSS variable candidates</h3>
<ul>
<li>Consider adding to <code>layout.css</code>:<br><code>--header-bg: #012851;</code><br><code>--header-accent: #B4B9FF;</code><br><code>--header-nav-active: #A1DCD8;</code></li>
<li>These are intentionally NOT in the dark-mode <code>@media (prefers-color-scheme: dark)</code> block — they are brand constants</li>
<li>If Tailwind 4 theme is configured, add:<br><code>brand-navy: #012851</code><br><code>brand-purple: #B4B9FF</code><br><code>brand-mint: #A1DCD8</code><br>to the <code>@theme</code> block in <code>layout.css</code></li>
<li>No backend changes required</li>
<li>No i18n key changes required</li>
<li>No new routes required</li>
</ul>
</div>
</div>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

2
frontend/.gitignore vendored
View File

@@ -6,7 +6,6 @@ node_modules
.netlify
.wrangler
/.svelte-kit
/.svelte-kit-backup
/build
# OS
@@ -30,4 +29,3 @@ src/lib/paraglide
# (committed as a stub; overwritten by the real spec after generation)
# src/lib/generated/api.ts
src/lib/paraglide_bak*
/coverage

View File

@@ -8,12 +8,7 @@ bun.lockb
# Miscellaneous
/static/
# Build artifacts
/.svelte-kit/
/.svelte-kit-backup/
# Generated files
/.svelte-kit-backup/
/src/lib/generated/
/src/lib/paraglide/
/src/lib/paraglide_bak*/
@@ -21,4 +16,3 @@ bun.lockb
# Test artifacts
/test-results/
/e2e/.auth/
/coverage/

View File

@@ -1,31 +0,0 @@
import type * as Kit from '@sveltejs/kit';
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
type MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;
type RouteParams = { id: string };
type RouteId = '/persons/[id]/edit';
type MaybeWithVoid<T> = {} extends T ? T | void : T;
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageServerParentData = EnsureDefined<import('../../../$types.js').LayoutServerData>;
type PageParentData = EnsureDefined<import('../../../$types.js').LayoutData>;
export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
type ExcludeActionFailure<T> = T extends Kit.ActionFailure<any> ? never : T extends void ? never : T;
type ActionsSuccess<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: ExcludeActionFailure<Awaited<ReturnType<T[Key]>>>; }[keyof T];
type ExtractActionFailure<T> = T extends Kit.ActionFailure<infer X> ? X extends void ? never : X : never;
type ActionsFailure<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: Exclude<ExtractActionFailure<Awaited<ReturnType<T[Key]>>>, void>; }[keyof T];
type ActionsExport = typeof import('../../../../../../../src/routes/persons/[id]/edit/+page.server.js').actions
export type SubmitFunction = Kit.SubmitFunction<Expand<ActionsSuccess<ActionsExport>>, Expand<ActionsFailure<ActionsExport>>>
export type ActionData = Expand<Kit.AwaitedActions<ActionsExport>> | null;
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('../../../../../../../src/routes/persons/[id]/edit/+page.server.js').load>>>>>>;
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;

View File

@@ -5,7 +5,7 @@
"value": "de",
"domain": "localhost",
"path": "/",
"expires": 1809337570.90398,
"expires": 1808565334.192108,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
@@ -15,7 +15,7 @@
"value": "Basic%20YWRtaW46YWRtaW4xMjM%3D",
"domain": "localhost",
"path": "/",
"expires": 1774863971.187596,
"expires": 1774091734.449243,
"httpOnly": true,
"secure": false,
"sameSite": "Strict"

View File

@@ -1,58 +0,0 @@
import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';
/**
* Automated accessibility checks using axe-core (wcag2a + wcag2aa).
* Authenticated pages use the stored admin session from playwright.config.ts.
* The login page test overrides to an unauthenticated context.
*/
const AUTHENTICATED_PAGES = [
{ name: 'home', path: '/' },
{ name: 'persons', path: '/persons' },
{ name: 'admin', path: '/admin' }
];
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
}
test.describe('Accessibility — authenticated pages', () => {
for (const { name, path } of AUTHENTICATED_PAGES) {
test(`${name} page has no critical wcag2a/wcag2aa violations`, async ({ page }) => {
await page.goto(path);
await page.waitForSelector('[data-hydrated]');
const results = await buildAxe(page).analyze();
if (results.violations.length > 0) {
const summary = results.violations
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
.join('\n');
console.log(`\nAccessibility violations on ${name}:\n${summary}`);
}
expect(results.violations).toEqual([]);
});
}
});
test.describe('Accessibility — login page', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('login page has no critical wcag2a/wcag2aa violations', async ({ page }) => {
await page.goto('/login');
await expect(page.getByLabel('Benutzername')).toBeVisible();
const results = await buildAxe(page).analyze();
if (results.violations.length > 0) {
const summary = results.violations
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
.join('\n');
console.log(`\nAccessibility violations on login:\n${summary}`);
}
expect(results.violations).toEqual([]);
});
});

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