Compare commits

..

1 Commits

Author SHA1 Message Date
Marcel
53b482c5f2 fix(e2e): fix admin tag test (use existing tag) and annotation locator
Some checks failed
CI / Unit & Component Tests (push) Successful in 2m37s
CI / Backend Unit Tests (push) Successful in 2m18s
CI / E2E Tests (push) Failing after 30m45s
- Admin tag test: "Familie" never existed in the database; use "Fest"
  which is a real seeded tag, with a matching rename-back to restore state
- Annotation hash test: the broad `[data-testid^="annotation-"]` locator
  also matched `annotation-side-panel` (always in DOM, even when
  off-screen); extend the :not() exclusion list to cover it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 11:34:42 +01:00
303 changed files with 2167 additions and 26214 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>
@@ -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

@@ -35,7 +35,7 @@ public class AnnotationController {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.ANNOTATE_ALL)
public DocumentAnnotation createAnnotation(
@PathVariable UUID documentId,
@RequestBody CreateAnnotationDTO dto,
@@ -47,7 +47,7 @@ public class AnnotationController {
@DeleteMapping("/{annotationId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.ANNOTATE_ALL)
public void deleteAnnotation(
@PathVariable UUID documentId,
@PathVariable UUID annotationId,

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

@@ -33,25 +33,25 @@ public class CommentController {
@PostMapping("/api/documents/{documentId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.ANNOTATE_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);
return commentService.postComment(documentId, null, dto.getContent(), author);
}
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.ANNOTATE_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);
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
}
// ─── Annotation comments ──────────────────────────────────────────────────
@@ -63,32 +63,32 @@ public class CommentController {
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.ANNOTATE_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);
return commentService.postComment(documentId, annotationId, dto.getContent(), author);
}
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.ANNOTATE_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);
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
}
// ─── Edit and delete (shared) ─────────────────────────────────────────────
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
@RequirePermission(Permission.ANNOTATE_ALL)
public DocumentComment editComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,

View File

@@ -11,14 +11,11 @@ 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;
@@ -167,9 +164,8 @@ public class DocumentController {
}
@GetMapping("/incomplete")
public List<IncompleteDocumentDTO> getIncomplete(
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) {
return documentService.findIncompleteDocuments(size);
public List<Document> getIncomplete() {
return documentService.findIncompleteDocuments();
}
@GetMapping("/incomplete/next")
@@ -179,12 +175,6 @@ public class DocumentController {
.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 +182,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 +201,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

@@ -2,12 +2,7 @@ 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

@@ -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,
@@ -54,10 +50,6 @@ public enum ErrorCode {
/** 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

@@ -1,12 +1,10 @@
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;
@@ -62,21 +60,4 @@ public class DocumentComment {
@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,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

@@ -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;
@@ -46,23 +42,20 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
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 +64,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;
@@ -32,33 +31,6 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// 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

@@ -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,7 +1,6 @@
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;
@@ -10,9 +9,7 @@ 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
@@ -20,23 +17,20 @@ import java.util.UUID;
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);
return withReplies(roots);
}
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
return withRepliesAndMentions(roots);
return withReplies(roots);
}
@Transactional
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
List<UUID> mentionedUserIds, AppUser author) {
public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) {
DocumentComment comment = DocumentComment.builder()
.documentId(documentId)
.annotationId(annotationId)
@@ -44,16 +38,11 @@ public class CommentService {
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
saveMentions(comment, mentionedUserIds);
DocumentComment saved = commentRepository.save(comment);
withMentionDTOs(saved);
notificationService.notifyMentions(mentionedUserIds, saved);
return saved;
return commentRepository.save(comment);
}
@Transactional
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content,
List<UUID> mentionedUserIds, AppUser author) {
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) {
DocumentComment target = commentRepository.findById(commentId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
@@ -71,15 +60,7 @@ public class CommentService {
.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;
return commentRepository.save(reply);
}
@Transactional
@@ -103,45 +84,13 @@ public class CommentService {
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);
});
private List<DocumentComment> withReplies(List<DocumentComment> roots) {
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId())));
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()))

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;
@@ -25,11 +23,8 @@ 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;
@@ -49,15 +44,6 @@ public class DocumentService {
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.
@@ -272,21 +258,13 @@ 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"));
@@ -328,9 +306,6 @@ 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);
}
@@ -338,12 +313,8 @@ public class DocumentService {
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 List<Document> findIncompleteDocuments() {
return documentRepository.findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
}
public Optional<Document> findNextIncompleteDocument(UUID currentId) {

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

@@ -4,10 +4,7 @@ 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 +20,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) {
@@ -77,36 +71,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 +92,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,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

@@ -81,29 +81,6 @@ class AnnotationControllerTest {
.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 {
@@ -155,51 +132,4 @@ class AnnotationControllerTest {
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

@@ -81,7 +81,7 @@ class CommentControllerTest {
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);
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
@@ -89,18 +89,6 @@ class CommentControllerTest {
.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
@@ -116,20 +104,7 @@ class CommentControllerTest {
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);
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
@@ -188,18 +163,6 @@ class CommentControllerTest {
.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
@@ -216,20 +179,7 @@ class CommentControllerTest {
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);
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
@@ -244,39 +194,10 @@ class CommentControllerTest {
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);
when(commentService.replyToComment(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;
@@ -27,9 +25,6 @@ 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 +53,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
@@ -237,80 +213,6 @@ class DocumentControllerTest {
.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
@@ -339,39 +241,16 @@ class DocumentControllerTest {
@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));
void getIncomplete_returns200_withList() throws Exception {
Document doc = Document.builder()
.id(UUID.randomUUID()).title("Unvollständig").originalFilename("scan.pdf").build();
when(documentService.findIncompleteDocuments()).thenReturn(List.of(doc));
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
@@ -406,38 +285,6 @@ class DocumentControllerTest {
.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

@@ -183,100 +183,4 @@ class AnnotationServiceTest {
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

@@ -20,9 +20,6 @@ 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;
@@ -33,8 +30,6 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
class CommentServiceTest {
@Mock CommentRepository commentRepository;
@Mock UserService userService;
@Mock NotificationService notificationService;
@InjectMocks CommentService commentService;
// ─── postComment ──────────────────────────────────────────────────────────
@@ -48,7 +43,7 @@ class CommentServiceTest {
.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);
DocumentComment result = commentService.postComment(docId, null, "Test", author);
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
}
@@ -61,28 +56,11 @@ class CommentServiceTest {
.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);
DocumentComment result = commentService.postComment(docId, null, "Test", 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
@@ -92,7 +70,7 @@ class CommentServiceTest {
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))
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
@@ -113,12 +91,11 @@ class CommentServiceTest {
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);
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author);
assertThat(result.getParentId()).isEqualTo(rootId);
}
@@ -133,59 +110,15 @@ class CommentServiceTest {
.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);
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", 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
@@ -300,181 +233,6 @@ class CommentServiceTest {
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() {

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

@@ -7,16 +7,12 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
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.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.mock.web.MockMultipartFile;
@@ -364,30 +360,12 @@ class DocumentServiceTest {
// ─── findIncompleteDocuments ──────────────────────────────────────────────
@Test
void findIncompleteDocuments_returnsDTOsWithIdAndTitle() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Unvollständig").build();
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(doc)));
void findIncompleteDocuments_returnsDocumentsOrderedByCreatedAtDesc() {
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
when(documentRepository.findByMetadataCompleteFalse(any(Sort.class))).thenReturn(List.of(doc));
List<IncompleteDocumentDTO> result = documentService.findIncompleteDocuments(3);
assertThat(result).hasSize(1);
assertThat(result.get(0).id()).isEqualTo(id);
assertThat(result.get(0).title()).isEqualTo("Unvollständig");
}
@Test
void findIncompleteDocuments_passesSizeToPageable() {
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
.thenReturn(Page.empty());
documentService.findIncompleteDocuments(3);
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
verify(documentRepository).findByMetadataCompleteFalse(captor.capture());
assertThat(captor.getValue().getPageSize()).isEqualTo(3);
assertThat(captor.getValue().getSort()).isEqualTo(Sort.by(Sort.Direction.DESC, "createdAt"));
assertThat(documentService.findIncompleteDocuments()).containsExactly(doc);
verify(documentRepository).findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
}
// ─── findNextIncompleteDocument ───────────────────────────────────────────
@@ -692,585 +670,4 @@ class DocumentServiceTest {
void titleFromFilename_null_returnsNull() {
assertThat(DocumentService.titleFromFilename(null)).isNull();
}
// ─── titleFromFilename — tryParseDate invalid cases ───────────────────────
@Test
void titleFromFilename_returnsStrippedName_whenIsoDateHasInvalidMonth() {
// 1965-13-12 → month 13 is invalid → tryParseDate returns null → fallback
assertThat(DocumentService.titleFromFilename("1965-13-12_Mueller_Hans.pdf"))
.isEqualTo("1965-13-12_Mueller_Hans");
}
@Test
void titleFromFilename_returnsStrippedName_whenIsoDateHasInvalidDay() {
// 1965-03-00 → day 0 is invalid → tryParseDate returns null → fallback
assertThat(DocumentService.titleFromFilename("1965-03-00_Mueller_Hans.pdf"))
.isEqualTo("1965-03-00_Mueller_Hans");
}
@Test
void titleFromFilename_returnsStrippedName_whenCompactDateHasInvalidMonth() {
// 19651312 → month 13 → invalid
assertThat(DocumentService.titleFromFilename("19651312_Mueller_Hans.pdf"))
.isEqualTo("19651312_Mueller_Hans");
}
@Test
void titleFromFilename_returnsStrippedName_whenCompactDateHasInvalidDay() {
// 19650300 → day 0 → invalid
assertThat(DocumentService.titleFromFilename("19650300_Mueller_Hans.pdf"))
.isEqualTo("19650300_Mueller_Hans");
}
@Test
void titleFromFilename_returnsStrippedName_whenStemHasNoExtension() {
// No dot → parseFilenameData returns null → titleFromFilename returns null? No,
// actually it returns null when filename is null, otherwise stripExtension is called.
// Without a dot, dot = -1, strip returns the whole string.
assertThat(DocumentService.titleFromFilename("Mueller_Hans_19650312"))
.isEqualTo("Mueller_Hans_19650312");
}
@Test
void titleFromFilename_returnsStrippedName_whenNamePartsContainNonLetters() {
// Parts with numbers/hyphens fail the \p{L}+ regex → returns null → fallback
assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller_H4ns.pdf"))
.isEqualTo("1965-03-12_Mueller_H4ns");
}
@Test
void titleFromFilename_returnsStrippedName_whenOnlyTwoParts() {
// "1965-03-12_Mueller.pdf" → less than 2 name parts → null → fallback
assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller.pdf"))
.isEqualTo("1965-03-12_Mueller");
}
@Test
void titleFromFilename_returnsStrippedName_whenIsoDateHasMonthZero() {
// 1965-00-12 → month 0 → m >= 1 is false → tryParseDate returns null
assertThat(DocumentService.titleFromFilename("1965-00-12_Mueller_Hans.pdf"))
.isEqualTo("1965-00-12_Mueller_Hans");
}
@Test
void titleFromFilename_returnsStrippedName_whenIsoDateHasDayAbove31() {
// 1965-03-32 → day 32 > 31 → d <= 31 is false → tryParseDate returns null
assertThat(DocumentService.titleFromFilename("1965-03-32_Mueller_Hans.pdf"))
.isEqualTo("1965-03-32_Mueller_Hans");
}
@Test
void titleFromFilename_returnsStrippedName_whenCompactDateHasMonthZero() {
// 19650012 → month 0 → m >= 1 is false
assertThat(DocumentService.titleFromFilename("19650012_Mueller_Hans.pdf"))
.isEqualTo("19650012_Mueller_Hans");
}
@Test
void titleFromFilename_returnsStrippedName_whenCompactDateHasDayAbove31() {
// 19650332 → day 32 > 31
assertThat(DocumentService.titleFromFilename("19650332_Mueller_Hans.pdf"))
.isEqualTo("19650332_Mueller_Hans");
}
// ─── getConversationFiltered ───────────────────────────────────────────────
@Test
void getConversationFiltered_passesGivenDates_whenFromAndToAreProvided() {
UUID senderId = UUID.randomUUID();
UUID receiverId = UUID.randomUUID();
LocalDate from = LocalDate.of(1940, 1, 1);
LocalDate to = LocalDate.of(1960, 12, 31);
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
when(documentRepository.findConversation(senderId, receiverId, from, to, sort))
.thenReturn(List.of());
documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
verify(documentRepository).findConversation(senderId, receiverId, from, to, sort);
}
@Test
void getConversationFiltered_usesMinDateForFrom_whenFromIsNull() {
UUID senderId = UUID.randomUUID();
UUID receiverId = UUID.randomUUID();
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
.thenReturn(List.of());
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
ArgumentCaptor<LocalDate> fromCaptor = ArgumentCaptor.forClass(LocalDate.class);
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), fromCaptor.capture(), any(LocalDate.class), eq(sort));
assertThat(fromCaptor.getValue()).isEqualTo(LocalDate.parse("0000-01-01"));
}
@Test
void getConversationFiltered_usesTodayForTo_whenToIsNull() {
UUID senderId = UUID.randomUUID();
UUID receiverId = UUID.randomUUID();
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
.thenReturn(List.of());
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
ArgumentCaptor<LocalDate> toCaptor = ArgumentCaptor.forClass(LocalDate.class);
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), toCaptor.capture(), eq(sort));
assertThat(toCaptor.getValue()).isEqualTo(LocalDate.now());
}
// ─── updateDocumentTags — empty tag in list ───────────────────────────────
@Test
void updateDocumentTags_skipsEmptyTagNames() {
UUID id = UUID.randomUUID();
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
Document doc = Document.builder().id(id).title("T").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(tagService.findOrCreate("Familie")).thenReturn(tag);
// List with empty string element — cleanName.isEmpty() branch hit
documentService.updateDocumentTags(id, List.of("Familie", " ", ""));
verify(tagService).findOrCreate("Familie");
verify(tagService, times(1)).findOrCreate(any()); // only "Familie" — others skipped
}
// ─── createDocument — with empty tag segment ──────────────────────────────
@Test
void createDocument_filtersEmptyTagSegments() throws Exception {
UUID docId = UUID.randomUUID();
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Test");
dto.setTags("Familie, ,"); // middle and trailing blank segments
when(documentRepository.save(any())).thenAnswer(inv -> {
Document d = inv.getArgument(0);
if (d.getId() == null) {
return Document.builder().id(docId).title(d.getTitle()).build();
}
return d;
});
when(documentRepository.findById(docId)).thenReturn(Optional.of(
Document.builder().id(docId).title("Test").build()));
when(tagService.findOrCreate("Familie")).thenReturn(tag);
documentService.createDocument(dto, null);
verify(tagService).findOrCreate("Familie");
verify(tagService, times(1)).findOrCreate(any());
}
// ─── createDocument — with sender and receivers ───────────────────────────
@Test
void createDocument_setsSender_whenSenderIdIsProvided() throws Exception {
UUID senderId = UUID.randomUUID();
Person sender = Person.builder().id(senderId).firstName("Hans").lastName("M").build();
UUID docId = UUID.randomUUID();
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Test");
dto.setSenderId(senderId);
when(documentRepository.save(any())).thenAnswer(inv -> {
Document d = inv.getArgument(0);
if (d.getId() == null) {
Document saved = Document.builder().id(docId).title(d.getTitle()).build();
return saved;
}
return d;
});
when(documentRepository.findById(docId)).thenReturn(Optional.of(
Document.builder().id(docId).title("Test").build()));
when(personService.getById(senderId)).thenReturn(sender);
documentService.createDocument(dto, null);
verify(personService).getById(senderId);
}
@Test
void createDocument_setsReceivers_whenReceiverIdsAreProvided() throws Exception {
UUID r1Id = UUID.randomUUID();
UUID r2Id = UUID.randomUUID();
UUID docId = UUID.randomUUID();
Person r1 = Person.builder().id(r1Id).firstName("A").lastName("B").build();
Person r2 = Person.builder().id(r2Id).firstName("C").lastName("D").build();
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Test");
dto.setReceiverIds(List.of(r1Id, r2Id));
when(documentRepository.save(any())).thenAnswer(inv -> {
Document d = inv.getArgument(0);
if (d.getId() == null) {
return Document.builder().id(docId).title(d.getTitle()).build();
}
return d;
});
when(documentRepository.findById(docId)).thenReturn(Optional.of(
Document.builder().id(docId).title("Test").build()));
when(personService.getAllById(List.of(r1Id, r2Id))).thenReturn(List.of(r1, r2));
documentService.createDocument(dto, null);
verify(personService).getAllById(List.of(r1Id, r2Id));
}
// ─── createDocument — empty file fallback and blank tags ─────────────────
@Test
void createDocument_usesUnbenanntesDocument_whenFileIsEmptyAndTitleIsNull() throws Exception {
// file != null but isEmpty() = true → falls through to title ternary
// title == null → "Unbenanntes Dokument"
MockMultipartFile emptyFile = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[0]);
DocumentUpdateDTO dto = new DocumentUpdateDTO(); // title = null
Document saved = Document.builder().id(UUID.randomUUID()).title("Unbenanntes Dokument")
.originalFilename("Unbenanntes Dokument").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, emptyFile);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).getOriginalFilename()).isEqualTo("Unbenanntes Dokument");
}
@Test
void createDocument_skipsTagProcessing_whenTagsIsBlank() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
dto.setTags(" "); // not null but blank → condition false
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
documentService.createDocument(dto, null);
verify(tagService, never()).findOrCreate(any());
}
@Test
void createDocument_setsMetadataCompleteFalse_whenReceiverIdsIsEmptyList() throws Exception {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Doc");
dto.setReceiverIds(List.of()); // not null but empty → !isEmpty() = false → false
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
when(documentRepository.save(any())).thenReturn(saved);
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
documentService.createDocument(dto, null);
verify(documentRepository, atLeastOnce()).save(captor.capture());
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse();
}
// ─── updateDocument — empty file, blank tags, empty receivers ────────────
@Test
void updateDocument_skipsTagProcessing_whenTagsIsBlank() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("T");
dto.setTags(" "); // not null but blank
documentService.updateDocument(id, dto, null);
verify(tagService, never()).findOrCreate(any());
}
@Test
void updateDocument_clearsReceivers_whenReceiverIdsIsEmptyList() throws Exception {
UUID id = UUID.randomUUID();
Person r1 = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>(Set.of(r1))).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("T");
dto.setReceiverIds(List.of()); // not null but empty → else → clear
documentService.updateDocument(id, dto, null);
assertThat(doc.getReceivers()).isEmpty();
}
@Test
void updateDocument_skipsFileUpload_whenNewFileIsEmpty() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
MockMultipartFile emptyFile = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[0]);
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("T");
documentService.updateDocument(id, dto, emptyFile);
verify(fileService, never()).uploadFile(any(), any());
}
// ─── titleFromFilename — no date in any position ──────────────────────────
@Test
void titleFromFilename_returnsStripped_whenNeitherFirstNorLastPartIsDate() {
// "Mueller_Hans_Schmitt.pdf" → 3 parts, none is a date → dateFromLast == null → null → stripExtension
assertThat(DocumentService.titleFromFilename("Mueller_Hans_Schmitt.pdf"))
.isEqualTo("Mueller_Hans_Schmitt");
}
@Test
void updateDocument_setsTags_withEmptySegmentsFiltered() throws Exception {
// Tags string with blank segment: "Familie, ,Reise" → only "Familie" and "Reise" used
UUID id = UUID.randomUUID();
Tag t1 = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
Tag t2 = Tag.builder().id(UUID.randomUUID()).name("Reise").build();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(tagService.findOrCreate("Familie")).thenReturn(t1);
when(tagService.findOrCreate("Reise")).thenReturn(t2);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("T");
dto.setTags("Familie, ,Reise"); // blank middle segment filtered
documentService.updateDocument(id, dto, null);
verify(tagService).findOrCreate("Familie");
verify(tagService).findOrCreate("Reise");
verify(tagService, times(2)).findOrCreate(any());
}
@Test
void createDocument_setsTags_whenTagsStringIsProvided() throws Exception {
UUID docId = UUID.randomUUID();
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Test");
dto.setTags("Familie");
when(documentRepository.save(any())).thenAnswer(inv -> {
Document d = inv.getArgument(0);
if (d.getId() == null) {
return Document.builder().id(docId).title(d.getTitle()).build();
}
return d;
});
when(documentRepository.findById(docId)).thenReturn(Optional.of(
Document.builder().id(docId).title("Test").build()));
when(tagService.findOrCreate("Familie")).thenReturn(tag);
documentService.createDocument(dto, null);
verify(tagService).findOrCreate("Familie");
}
// ─── updateDocument — with sender / clear receivers ──────────────────────
@Test
void updateDocument_clearsSender_whenSenderIdIsNull() throws Exception {
UUID id = UUID.randomUUID();
Person existingSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
Document doc = Document.builder().id(id).title("T").sender(existingSender).receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); // also for updateDocumentTags
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("T");
// senderId is null — should clear sender
documentService.updateDocument(id, dto, null);
verify(documentRepository, atLeastOnce()).save(argThat(d -> d.getSender() == null));
}
@Test
void updateDocument_setsReceivers_whenReceiverIdsAreProvided() throws Exception {
UUID id = UUID.randomUUID();
UUID r1Id = UUID.randomUUID();
Person r1 = Person.builder().id(r1Id).firstName("A").lastName("B").build();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.getAllById(List.of(r1Id))).thenReturn(List.of(r1));
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("T");
dto.setReceiverIds(List.of(r1Id));
documentService.updateDocument(id, dto, null);
verify(personService).getAllById(List.of(r1Id));
}
@Test
void updateDocument_setsTags_whenTagsStringIsProvided() throws Exception {
UUID id = UUID.randomUUID();
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Reise").build();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(tagService.findOrCreate("Reise")).thenReturn(tag);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("T");
dto.setTags("Reise");
documentService.updateDocument(id, dto, null);
verify(tagService).findOrCreate("Reise");
}
@Test
void updateDocument_setsSender_whenSenderIdIsProvided() throws Exception {
// dto.getSenderId() != null → true branch: sets sender via personService
UUID id = UUID.randomUUID();
UUID senderId = UUID.randomUUID();
Person sender = Person.builder().id(senderId).firstName("Hans").lastName("M").build();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.getById(senderId)).thenReturn(sender);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("T");
dto.setSenderId(senderId);
documentService.updateDocument(id, dto, null);
verify(personService).getById(senderId);
assertThat(doc.getSender()).isEqualTo(sender);
}
// ─── stripExtension / parseFilenameData — null guard branches ────────────
@Test
void stripExtension_returnsNull_whenFilenameIsNull() throws Exception {
// filename == null = true → null guard branch in private static method
java.lang.reflect.Method method = DocumentService.class
.getDeclaredMethod("stripExtension", String.class);
method.setAccessible(true);
String result = (String) method.invoke(null, (String) null);
assertThat(result).isNull();
}
@Test
void parseFilenameData_returnsNull_whenFilenameIsNull() throws Exception {
// filename == null = true → null guard branch in private static method
java.lang.reflect.Method method = DocumentService.class
.getDeclaredMethod("parseFilenameData", String.class);
method.setAccessible(true);
Object result = method.invoke(null, (String) null);
assertThat(result).isNull();
}
// ─── searchDocuments — status filter ─────────────────────────────────────
@Test
void searchDocuments_passesStatusSpecificationToRepository() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
.thenReturn(List.of());
documentService.searchDocuments(null, null, null, null, null, null, DocumentStatus.REVIEWED);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
}
@Test
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
.thenReturn(List.of());
documentService.searchDocuments(null, null, null, null, null, null, null);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
}
// ─── getRecentActivity ────────────────────────────────────────────────────
@Test
void getRecentActivity_returnsMostRecentlyUpdatedDocuments() {
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Oldest").build();
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Middle").build();
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Newest").build();
Page<Document> page = new PageImpl<>(List.of(doc3, doc2));
when(documentRepository.findAll(any(Pageable.class))).thenReturn(page);
List<Document> result = documentService.getRecentActivity(2);
assertThat(result).hasSize(2);
assertThat(result).containsExactly(doc3, doc2);
}
@Test
void getRecentActivity_usesPageRequestWithSizeLimit_notFindAll() {
Page<Document> page = new PageImpl<>(List.of());
when(documentRepository.findAll(any(Pageable.class))).thenReturn(page);
documentService.getRecentActivity(3);
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
verify(documentRepository).findAll(captor.capture());
assertThat(captor.getValue().getPageSize()).isEqualTo(3);
assertThat(captor.getValue().getSort())
.isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
}
// ─── getConversationFiltered (single-person mode) ─────────────────────────
@Test
void getConversationFiltered_callsSinglePersonQuery_whenReceiverIdIsNull() {
UUID personId = UUID.randomUUID();
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
when(documentRepository.findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort)))
.thenReturn(List.of());
documentService.getConversationFiltered(personId, null, null, null, sort);
verify(documentRepository).findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort));
verify(documentRepository, never()).findConversation(any(), any(), any(), any(), any());
}
@Test
void getConversationFiltered_callsBilateralQuery_whenReceiverIdIsSet() {
UUID senderId = UUID.randomUUID();
UUID receiverId = UUID.randomUUID();
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort)))
.thenReturn(List.of());
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort));
verify(documentRepository, never()).findSinglePersonCorrespondence(any(), any(), any(), any());
}
}

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

@@ -4,23 +4,15 @@ 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.*;
@@ -90,111 +82,4 @@ class FileServiceTest {
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

File diff suppressed because it is too large Load Diff

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

5
frontend/.gitignore vendored
View File

@@ -6,7 +6,6 @@ node_modules
.netlify
.wrangler
/.svelte-kit
/.svelte-kit-backup
/build
# OS
@@ -30,7 +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
# Playwright auth state — regenerated at the start of each CI run via auth.setup.ts
e2e/.auth/

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": 1808896929.897686,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
@@ -15,7 +15,7 @@
"value": "Basic%20YWRtaW46YWRtaW4xMjM%3D",
"domain": "localhost",
"path": "/",
"expires": 1774863971.187596,
"expires": 1774423330.233039,
"httpOnly": true,
"secure": false,
"sameSite": "Strict"

View File

@@ -1,109 +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 — dark mode (system preference)', () => {
for (const { name, path } of AUTHENTICATED_PAGES) {
test(`${name} page has no wcag2a/wcag2aa violations in prefers-color-scheme: dark`, async ({
browser
}) => {
const context = await browser.newContext({
colorScheme: 'dark',
storageState: 'e2e/.auth/user.json'
});
const page = await context.newPage();
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} (dark/media):\n${summary}`);
}
await context.close();
expect(results.violations).toEqual([]);
});
}
});
test.describe('Accessibility — dark mode (manual toggle)', () => {
for (const { name, path } of AUTHENTICATED_PAGES) {
test(`${name} page has no wcag2a/wcag2aa violations with data-theme='dark'`, async ({
page
}) => {
await page.goto(path);
await page.waitForSelector('[data-hydrated]');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
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} (dark/manual):\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([]);
});
});

View File

@@ -180,19 +180,19 @@ test.describe('Admin — tag management', () => {
// Wait for the tags list to render after the tab switch
await page.waitForSelector('ul > li');
// Hover over the "Familie" row to reveal the opacity-0 action buttons
const familieRow = page
// Hover over the "Fest" row to reveal the opacity-0 action buttons
const festRow = page
.locator('ul > li')
.filter({ has: page.locator('span', { hasText: /^Familie$/ }) });
await familieRow.hover();
await familieRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
.filter({ has: page.locator('span', { hasText: /^Fest$/ }) });
await festRow.hover();
await festRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
// After clicking edit, {#if editingTagId} replaces the span with a form —
// the familieRow filter no longer matches, so we find the input directly.
await page.locator('input[name="name"]').fill('Familie (E2E)');
// the festRow filter no longer matches, so we find the input directly.
await page.locator('input[name="name"]').fill('Fest (E2E)');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Familie (E2E)')).toBeVisible();
await expect(page.getByText('Fest (E2E)')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-tag-renamed.png' });
});
@@ -205,14 +205,14 @@ test.describe('Admin — tag management', () => {
const renamedRow = page
.locator('ul > li')
.filter({ has: page.locator('span', { hasText: /^Familie \(E2E\)$/ }) });
.filter({ has: page.locator('span', { hasText: /^Fest \(E2E\)$/ }) });
await renamedRow.hover();
await renamedRow.getByRole('button', { name: 'Schlagwort bearbeiten' }).click();
await page.locator('input[name="name"]').fill('Familie');
await page.locator('input[name="name"]').fill('Fest');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Familie')).toBeVisible();
await expect(page.getByText('Fest')).toBeVisible();
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
});
});

View File

@@ -1,29 +0,0 @@
import { test, expect } from '@playwright/test';
import { login } from './helpers/auth';
/**
* Classic Split layout — verifies the right column visibility guard.
*
* The right column (DropZone + NeedsMetadata queue) is only rendered when
* `canWrite === true` or there are incomplete docs. A read-only user with a
* complete archive must never see an empty 300px ghost column.
*/
test.describe('Dashboard Classic Split — write user', () => {
test('right column is visible for admin user', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('dashboard-right-column')).toBeVisible();
});
});
test.describe('Dashboard Classic Split — read-only user', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test.beforeEach(async ({ page }) => {
await login(page, 'reader', 'reader123');
});
test('right column is absent for read-only user with no incomplete docs', async ({ page }) => {
await expect(page.getByTestId('dashboard-right-column')).not.toBeVisible();
});
});

View File

@@ -1,62 +0,0 @@
/**
* Dashboard proofshots — seeds the admin account with test data so every
* widget is visible, then captures 6 screenshots (3 viewports × 2 themes).
*
* Seeded data is removed in afterAll so it doesn't pollute other tests.
*/
import { test } from '@playwright/test';
import { execSync } from 'child_process';
import { captureProofshots } from './proofshots';
// A real document that exists in the dev DB (most recently updated)
const SEED_DOC_ID = '24580ce9-9765-40b1-ac59-b0ab15160ce0';
const SEED_DOC_TITLE = 'Brief aus dem Krieg';
// Real comment IDs used as reference_id for deep-linking
const COMMENT_IDS = [
'46c5171f-1721-4085-a7ed-1eef7b4effb8',
'a09cefe4-ddf8-47fa-addc-5c582183b459'
];
const psql = (sql: string) =>
execSync(
`docker exec archive-db psql -U archive_user family_archive_db -c "${sql.replace(/"/g, '\\"')}"`
);
test.beforeAll(() => {
// Insert a MENTION and a REPLY notification for the admin user so the
// notifications widget is populated in the screenshots.
psql(`
INSERT INTO notifications (recipient_id, type, document_id, reference_id, read, actor_name)
SELECT id, 'MENTION', '${SEED_DOC_ID}', '${COMMENT_IDS[0]}', false, 'Berit Hoffmann'
FROM users WHERE username = 'admin';
INSERT INTO notifications (recipient_id, type, document_id, reference_id, read, actor_name)
SELECT id, 'REPLY', '${SEED_DOC_ID}', '${COMMENT_IDS[1]}', false, 'Marcel Raddatz'
FROM users WHERE username = 'admin';
`);
});
test.afterAll(() => {
// Remove only the seeded rows (identified by the sentinel actor names)
psql(`
DELETE FROM notifications
WHERE actor_name IN ('Berit Hoffmann', 'Marcel Raddatz')
AND recipient_id = (SELECT id FROM users WHERE username = 'admin');
`);
});
captureProofshots('/', 'dashboard', {
setup: async (page) => {
// Navigate to '/' first so the browser has an origin for localStorage,
// then inject the lastVisited entry directly — no document page load needed.
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
await page.evaluate(
({ id, title }) => {
localStorage.setItem('familienarchiv.lastVisited', JSON.stringify({ id, title }));
},
{ id: SEED_DOC_ID, title: SEED_DOC_TITLE }
);
}
});

View File

@@ -461,9 +461,11 @@ test.describe('PDF annotations — file hash versioning', () => {
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
// Use :not() to exclude the outdated-notice element whose testid also starts with "annotation-"
// Use :not() to exclude the outdated-notice and side-panel elements whose testid also starts with "annotation-"
await expect(
page.locator('[data-testid^="annotation-"]:not([data-testid="annotation-outdated-notice"])')
page.locator(
'[data-testid^="annotation-"]:not([data-testid="annotation-outdated-notice"]):not([data-testid="annotation-side-panel"])'
)
).toHaveCount(0, { timeout: 8000 });
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
timeout: 5000

View File

@@ -1,88 +0,0 @@
import { test, expect } from '@playwright/test';
// Expected focus ring resolved colors
// Light: --c-focus-ring: #012851 (brand-navy)
const FOCUS_RING_LIGHT = 'rgb(1, 40, 81)';
// Dark: --c-focus-ring: #a1dcd8 (brand-mint)
const FOCUS_RING_DARK = 'rgb(161, 220, 216)';
test.describe('Focus ring token — CSS custom property', () => {
test('--c-focus-ring is defined in light mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
const value = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue('--c-focus-ring').trim()
);
expect(value).toBe('#012851');
});
test('--c-focus-ring is defined in dark mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
const value = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue('--c-focus-ring').trim()
);
expect(value).toBe('#a1dcd8');
});
});
test.describe('Focus ring — header interactive elements', () => {
test('ThemeToggle has brand-navy ring in light mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: /dark mode|dunkelmodus/i }).focus();
const boxShadow = await page.evaluate(
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
);
expect(boxShadow).toContain(FOCUS_RING_LIGHT);
});
test('AppNav link has brand-mint ring in dark mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
// Focus first desktop nav link
await page.locator('header nav').getByRole('link').first().focus();
const boxShadow = await page.evaluate(
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
);
expect(boxShadow).toContain(FOCUS_RING_DARK);
});
});
test.describe('Focus ring — form inputs', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('login username input has brand-mint ring in dark mode', async ({ page }) => {
await page.goto('/login');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
await page.locator('#username').focus();
const boxShadow = await page.evaluate(
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
);
expect(boxShadow).toContain(FOCUS_RING_DARK);
});
});
test.describe('Focus ring — PersonTypeahead', () => {
test('PersonTypeahead input has brand-navy ring in light mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
// Open advanced filter panel to expose the sender PersonTypeahead
await page.getByRole('button', { name: /filter/i }).click();
await page.waitForSelector('#senderId-search');
await page.locator('#senderId-search').focus();
const boxShadow = await page.evaluate(
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
);
expect(boxShadow).toContain(FOCUS_RING_LIGHT);
});
});

View File

@@ -1,118 +0,0 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
// #012851 — brand-navy, set as --c-header in layout.css (both light and dark mode)
const BRAND_NAVY = 'rgb(1, 40, 81)';
test.describe('Header — brand-navy background', () => {
test('header background is brand-navy in light mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
const bg = await page.locator('header').evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe(BRAND_NAVY);
});
test('header passes accessibility audit in light mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
const results = await new AxeBuilder({ page }).include('header').analyze();
expect(results.violations).toEqual([]);
});
test('header background stays brand-navy after switching to dark mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
await page
.getByRole('banner')
.getByRole('button', { name: /dark mode/i })
.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const bg = await page.locator('header').evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe(BRAND_NAVY);
});
test('header passes accessibility audit in dark mode', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
await page
.getByRole('banner')
.getByRole('button', { name: /dark mode/i })
.click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
const results = await new AxeBuilder({ page }).include('header').analyze();
expect(results.violations).toEqual([]);
});
test('logo text is visible at 375px viewport', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/');
await expect(page.getByRole('banner').getByText('Familienarchiv')).toBeVisible();
});
test('hamburger menu opens on tablet viewport (768px)', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/');
await expect(page.getByRole('navigation')).toBeVisible();
const hamburger = page.getByRole('button', { name: /menü öffnen/i });
await expect(hamburger).toBeVisible();
await hamburger.click();
await expect(
page.getByRole('navigation', { name: /mobile/i }).or(page.locator('#mobile-nav'))
).toBeVisible();
});
});
test.describe('Login page — AuthHeader', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('login page has brand-navy header with language switcher', async ({ page }) => {
await page.goto('/login');
const header = page.locator('header');
await expect(header).toBeVisible();
const bg = await header.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe(BRAND_NAVY);
await expect(header.getByRole('button', { name: 'DE' })).toBeVisible();
});
test('login page header passes accessibility audit', async ({ page }) => {
await page.goto('/login');
await expect(page.locator('header')).toBeVisible();
const results = await new AxeBuilder({ page }).include('header').analyze();
expect(results.violations).toEqual([]);
});
});
test.describe('Forgot-password page — AuthHeader', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('forgot-password page has brand-navy header', async ({ page }) => {
await page.goto('/forgot-password');
const header = page.locator('header');
await expect(header).toBeVisible();
const bg = await header.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).toBe(BRAND_NAVY);
});
test('forgot-password page header passes accessibility audit', async ({ page }) => {
await page.goto('/forgot-password');
await expect(page.locator('header')).toBeVisible();
const results = await new AxeBuilder({ page }).include('header').analyze();
expect(results.violations).toEqual([]);
});
});

View File

@@ -1,127 +0,0 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
}
test.describe('Korrespondenz empty state', () => {
test('shows the search heading when no person is selected', async ({ page }) => {
await page.goto('/korrespondenz');
await expect(page.getByText(/Korrespondenz durchsuchen/i)).toBeVisible();
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
await page.screenshot({ path: 'test-results/e2e/korrespondenz-empty.png' });
});
test('nav link goes to /korrespondenz', async ({ page }) => {
await page.goto('/');
// Click the nav link (desktop text or mobile icon)
const navLink = page.getByRole('link', { name: /Korrespondenz/i }).first();
await navLink.click();
await expect(page).toHaveURL(/\/korrespondenz/);
});
});
test.describe('Korrespondenz single-person mode', () => {
test('shows hint bar and documents when navigated with senderId', async ({ page }) => {
// Get a real person ID from the persons list
await page.goto('/persons');
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
await firstPersonLink.click();
await page.waitForURL(/\/persons\/.+/);
// Extract the person ID from the URL
const personId = page.url().split('/persons/')[1].split('?')[0];
// Navigate to korrespondenz in single-person mode
await page.goto(`/korrespondenz?senderId=${personId}`);
// Hint bar should be visible
await expect(page.getByText(/Alle Briefe von/i)).toBeVisible();
// Filter controls should be active (not dimmed)
const filterStrip = page.locator('[aria-disabled="false"]').first();
await expect(filterStrip).toBeAttached();
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
await page.screenshot({ path: 'test-results/e2e/korrespondenz-single-person.png' });
});
test('sort toggle changes URL direction param', async ({ page }) => {
await page.goto('/persons');
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
await firstPersonLink.click();
await page.waitForURL(/\/persons\/.+/);
const personId = page.url().split('/persons/')[1].split('?')[0];
await page.goto(`/korrespondenz?senderId=${personId}&dir=DESC`);
await page.getByTestId('conv-sort-btn').click();
await expect(page).toHaveURL(/dir=ASC/);
await page.screenshot({ path: 'test-results/e2e/korrespondenz-sort-asc.png' });
});
});
test.describe('Korrespondenz bilateral mode', () => {
test('shows asymmetry bar when both persons have shared documents', async ({ page }) => {
// Navigate to a person then follow a co-correspondent suggestion if available
await page.goto('/persons');
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
await firstPersonLink.click();
await page.waitForURL(/\/persons\/.+/);
const senderId = page.url().split('/persons/')[1].split('?')[0];
// Try to find a co-correspondent link from the person detail page
const corrLink = page
.locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]')
.first();
if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) {
await corrLink.click();
await page.waitForURL(/\/korrespondenz\?.*receiverId=/);
// Hint bar should NOT be shown in bilateral mode
await expect(page.getByText(/Alle Briefe von/i)).not.toBeVisible();
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
await page.screenshot({ path: 'test-results/e2e/korrespondenz-bilateral.png' });
} else {
// E2E seed must include bilateral correspondents — a missing link is a test failure.
throw new Error(
`No bilateral correspondent links found for person ${senderId}. Ensure the E2E seed contains at least one bilateral correspondence pair.`
);
}
});
test('swap button swaps sender and receiver in URL', async ({ page }) => {
await page.goto('/persons');
const firstPersonLink = page.locator('a[href^="/persons/"]').first();
await firstPersonLink.click();
await page.waitForURL(/\/persons\/.+/);
const senderId = page.url().split('/persons/')[1].split('?')[0];
const corrLink = page
.locator('a[href*="/korrespondenz?senderId="][href*="receiverId="]')
.first();
if (await corrLink.isVisible({ timeout: 2000 }).catch(() => false)) {
const href = await corrLink.getAttribute('href');
await corrLink.click();
await page.waitForURL(/\/korrespondenz\?.*receiverId=/);
// Extract original receiverId from the href
const url = new URL(href!, 'http://x');
const originalReceiverId = url.searchParams.get('receiverId')!;
// Click swap
await page.getByTestId('conv-swap-btn').click();
// After swap the former receiver is now senderId
await expect(page).toHaveURL(new RegExp(`senderId=${originalReceiverId}`));
await page.screenshot({ path: 'test-results/e2e/korrespondenz-swapped.png' });
} else {
test.skip(true, `No bilateral correspondent links found for person ${senderId}`);
}
});
});

View File

@@ -80,7 +80,8 @@ test.describe('Password reset', () => {
await page.locator('input[name="currentPassword"]').fill(newPassword);
await page.locator('input[name="newPassword"]').fill(originalPassword);
await page.locator('input[name="confirmPassword"]').fill(originalPassword);
await page.getByTestId('submit-password').click();
// Profile page has two "Speichern" buttons — the password form is the last one
await page.locator('button[type="submit"]').last().click();
// After changing password, auth_token is stale → redirect to login
await expect(page).toHaveURL(/\/login/);

View File

@@ -1,85 +0,0 @@
/**
* Shared proofshot helper for Playwright.
*
* Basic usage:
* import { captureProofshots } from './proofshots';
* captureProofshots('/persons', 'persons');
*
* With per-test setup (e.g. seed localStorage before navigation):
* captureProofshots('/persons', 'persons', {
* setup: async (page) => {
* await page.goto('/persons/some-id'); // populates any localStorage state
* }
* });
*
* The setup callback runs before each screenshot's page.goto(url), so any
* localStorage values it writes persist into the main navigation.
*
* Screenshots are saved to proofshot-artifacts/{featureName}/.
*/
import { type Page, test } from '@playwright/test';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const viewports = [
{ name: 'mobile', width: 390, height: 844 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1440, height: 900 }
];
interface ProofshotOptions {
/**
* Optional async callback that runs before each screenshot's page.goto(url).
* Use it to seed localStorage, visit a prerequisite page, etc.
*/
setup?: (page: Page) => Promise<void>;
}
/**
* Registers Playwright tests that navigate to `url`, apply each theme,
* and capture full-page screenshots at all standard viewports.
*
* @param url The path to screenshot (e.g. '/', '/persons', '/admin')
* @param featureName Used as the output directory name and screenshot file prefix
* @param options Optional setup callback and other options
*/
export function captureProofshots(
url: string,
featureName: string,
options?: ProofshotOptions
): void {
const outDir = path.join(__dirname, '../../proofshot-artifacts', featureName);
fs.mkdirSync(outDir, { recursive: true });
for (const vp of viewports) {
for (const theme of ['light', 'dark'] as const) {
test(`${featureName} ${vp.name} ${theme}`, async ({ page }) => {
// Run optional setup before main navigation (e.g. seed localStorage)
if (options?.setup) {
await options.setup(page);
}
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(url);
// Apply theme via data-theme attribute and localStorage
await page.evaluate((t) => {
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
}, theme);
// 'networkidle' is unreliable in SvelteKit dev mode due to the HMR WebSocket.
await page.waitForLoadState('domcontentloaded');
await page.waitForSelector('main', { state: 'visible' });
await page.screenshot({
path: path.join(outDir, `${featureName}-${vp.name}-${theme}.png`),
fullPage: true
});
});
}
}
}

View File

@@ -60,48 +60,6 @@ test.describe('Theme toggle', () => {
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
});
test('header uses --c-header token background in dark mode', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
const headerBg = await page.evaluate(() => {
const header = document.querySelector('header');
return header ? getComputedStyle(header).backgroundColor : null;
});
// --c-header in dark mode = #012851 (brand navy) → rgb(1, 40, 81)
expect(headerBg).toBe('rgb(1, 40, 81)');
});
test('color-scheme is dark when data-theme=dark is set', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
const colorScheme = await page.evaluate(
() => getComputedStyle(document.documentElement).colorScheme
);
expect(colorScheme).toBe('dark');
});
test('color-scheme is dark in prefers-color-scheme: dark media', async ({ browser }) => {
const context = await browser.newContext({
colorScheme: 'dark',
storageState: 'e2e/.auth/user.json'
});
const page = await context.newPage();
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
const colorScheme = await page.evaluate(
() => getComputedStyle(document.documentElement).colorScheme
);
await context.close();
expect(colorScheme).toBe('dark');
});
test('saved theme is applied before first paint (no flash)', async ({ page }) => {
// Set dark theme in localStorage before navigating
await page.goto('/');

View File

@@ -16,7 +16,7 @@
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
"nav_documents": "Dokumente",
"nav_persons": "Personen",
"nav_conversations": "Korrespondenz",
"nav_conversations": "Konversationen",
"nav_admin": "Admin",
"nav_logout": "Abmelden",
"btn_save": "Speichern",
@@ -120,41 +120,23 @@
"person_role_sender": "Gesendet",
"person_role_receiver": "Empfangen",
"person_co_correspondents_heading": "Häufige Korrespondenten",
"person_correspondents_hint": "klicken für Konversation",
"person_show_more": "+ {count} weitere anzeigen",
"conv_heading": "Korrespondenz",
"conv_subtitle": "Briefwechsel einer Person durchsuchen — mit oder ohne Korrespondent.",
"conv_heading": "Konversationen",
"conv_subtitle": "Verfolgen Sie den Schriftverkehr zwischen zwei Personen chronologisch.",
"conv_label_person_a": "Person A (Absender)",
"conv_label_person_b": "Korrespondent",
"conv_label_person_b": "Person B (Empfänger)",
"conv_label_from": "Zeitraum von",
"conv_label_to": "Zeitraum bis",
"conv_sort_label": "Sortierung:",
"conv_sort_newest": "Neueste zuerst",
"conv_sort_oldest": "Älteste zuerst",
"conv_empty_heading": "Korrespondenz durchsuchen",
"conv_empty_text": "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.",
"conv_empty_heading": "Wählen Sie zwei Personen aus",
"conv_empty_text": "Die Korrespondenz wird hier angezeigt.",
"conv_no_results_heading": "Keine Dokumente gefunden.",
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
"conv_swap_btn": "Personen tauschen",
"conv_summary": "{count} Dokumente · {yearFrom}{yearTo}",
"conv_new_doc_link": "Neues Dokument in dieser Korrespondenz",
"conv_label_correspondent_optional": "Korrespondent",
"conv_hint_single_person": "Alle Briefe von {name} — wähle einen Korrespondenten oben um einzugrenzen",
"conv_hint_single_person_filtered": "Alle Briefe von {name} · {from}{to} · {sortLabel}",
"conv_strip_period": "Zeitraum",
"conv_strip_from_placeholder": "Von…",
"conv_strip_to_placeholder": "Bis…",
"conv_strip_all_correspondents": "Alle Korrespondenten",
"conv_strip_sort_newest": "Neueste",
"conv_strip_sort_oldest": "Älteste",
"conv_suggestions_heading": "Häufigste Korrespondenten",
"conv_suggestions_all_label": "Alle Korrespondenten von {name}",
"conv_letters_count": "{count} Briefe",
"conv_empty_search_placeholder": "Person suchen…",
"conv_empty_recent_label": "Zuletzt geöffnet",
"conv_asym_sent": "{count} von {name} →",
"conv_asym_received": "{count} von {name} ←",
"conv_no_party": "—",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Benutzer",
"admin_tab_groups": "Gruppen",
@@ -172,14 +154,6 @@
"admin_multiselect_hint_full": "Strg+Klick für Mehrfachauswahl",
"admin_section_tags": "Schlagworte",
"admin_tags_warning": "Warnung: Umbenennen oder Löschen wirkt sich auf alle verknüpften Dokumente aus.",
"admin_tags_list_title": "Alle Schlagworte",
"admin_tags_empty": "Keine Schlagworte vorhanden.",
"admin_tags_select_prompt": "W\u00e4hle ein Schlagwort aus der Liste.",
"admin_tag_edit_heading": "Schlagwort: {name}",
"admin_tag_updated": "Schlagwort umbenannt.",
"admin_unsaved_warning": "Du hast ungespeicherte Änderungen speichere oder verwerfe, bevor du wechselst.",
"admin_btn_collapse_list": "Liste einklappen",
"admin_btn_expand_list": "Liste ausklappen",
"admin_btn_edit_tag_label": "Schlagwort bearbeiten",
"admin_tag_delete_confirm": "Wirklich löschen? Das Schlagwort wird aus allen Dokumenten entfernt.",
"admin_btn_delete_tag_label": "Schlagwort löschen",
@@ -192,28 +166,6 @@
"admin_group_name_placeholder": "Gruppenname (z.B. Editoren)",
"admin_user_delete_confirm": "Benutzer {username} wirklich löschen?",
"admin_btn_new_user": "Neuer Benutzer",
"admin_users_list_title": "Alle Benutzer",
"admin_users_search_placeholder": "Benutzer suchen\u2026",
"admin_users_empty": "Keine Benutzer vorhanden.",
"admin_users_select_prompt": "W\u00e4hle einen Benutzer aus der Liste.",
"admin_btn_new_group": "Neue Gruppe",
"admin_groups_list_title": "Alle Gruppen",
"admin_groups_empty": "Keine Gruppen vorhanden.",
"admin_groups_select_prompt": "W\u00e4hle eine Gruppe aus der Liste.",
"admin_groups_permission_count": "{count} Berechtigungen",
"admin_group_new_heading": "Neue Gruppe anlegen",
"admin_group_edit_heading": "Gruppe: {name}",
"admin_group_updated": "Gruppe gespeichert.",
"admin_group_created": "Gruppe erstellt.",
"admin_groups_section_standard": "Standard",
"admin_groups_section_administrative": "Administrativ",
"admin_perm_read_all": "Nur lesen",
"admin_perm_annotate_all": "Lesen & Annotieren",
"admin_perm_write_all": "Lesen & Schreiben",
"admin_perm_admin": "Vollzugriff (Admin)",
"admin_perm_admin_user": "Benutzer verwalten",
"admin_perm_admin_tag": "Schlagworte verwalten",
"admin_perm_admin_permission": "Berechtigungen verwalten",
"admin_user_new_heading": "Neuen Benutzer anlegen",
"admin_user_edit_heading": "Benutzer bearbeiten: {username}",
"admin_user_created": "Benutzer wurde erstellt.",
@@ -223,12 +175,6 @@
"admin_label_initial_password": "Passwort",
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
"doc_download_title": "Herunterladen",
"topbar_back_label": "Zurück zur Dokumentenliste",
"topbar_more_actions": "Weitere Aktionen",
"topbar_overflow_more": "+{count} weitere",
"topbar_overflow_suffix": "weitere",
"topbar_overflow_heading": "Weitere Empfänger",
"topbar_overflow_show": "{count} weitere Empfänger anzeigen",
"doc_tag_filter_title": "Nach {name} filtern",
"doc_conversation_title": "Konversation anzeigen",
"doc_preview_iframe_title": "Dokumentvorschau",
@@ -303,14 +249,6 @@
"admin_system_backfill_hashes_description": "Berechnet den SHA-256-Hash für alle bereits hochgeladenen Dokumente, die noch keinen Hash haben. Dadurch werden Annotationen korrekt mit ihrer Dateiversion verknüpft und wieder angezeigt.",
"admin_system_backfill_hashes_btn": "Datei-Hashes berechnen",
"admin_system_backfill_hashes_success": "{count} Dokumente wurden aktualisiert.",
"admin_system_import_heading": "Massenimport",
"admin_system_import_description": "Importiert Dokumente und Metadaten aus der Importdatei im /import-Verzeichnis.",
"admin_system_import_btn_start": "Import starten",
"admin_system_import_btn_retry": "Erneut starten",
"admin_system_import_status_idle": "Kein Import gestartet.",
"admin_system_import_status_running": "Import läuft…",
"admin_system_import_status_done": "Import abgeschlossen {count} Dokumente verarbeitet.",
"admin_system_import_status_failed": "Fehler: {message}",
"comp_expandable_show_more": "Mehr anzeigen",
"comp_expandable_show_less": "Weniger anzeigen",
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
@@ -327,7 +265,6 @@
"doc_panel_tab_history": "Verlauf",
"doc_panel_annotate": "Annotieren",
"doc_panel_annotate_stop": "Fertig",
"doc_panel_annotate_hint": "Klicken und ziehen Sie, um einen Bereich zu markieren",
"doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen",
@@ -357,71 +294,5 @@
"enrich_done_body": "Alle Dokumente wurden bearbeitet.",
"enrich_back_to_list": "Zurück zur Liste",
"comment_empty_hint": "Noch keine Kommentare starte die Diskussion!",
"comment_start_discussion": "Diskussion starten →",
"notification_bell_label": "Benachrichtigungen",
"notification_bell_unread_label": "{count} ungelesene Benachrichtigungen",
"notification_mark_all_read": "Alle gelesen",
"notification_empty": "Keine neuen Benachrichtigungen",
"notification_type_reply": "{actor} hat auf deinen Kommentar geantwortet",
"notification_type_mention": "{actor} hat dich in einem Kommentar erwähnt",
"notification_prefs_heading": "Benachrichtigungen",
"notification_pref_reply": "E-Mail, wenn jemand auf meinen Kommentar antwortet",
"notification_pref_mention": "E-Mail, wenn jemand mich in einem Kommentar erwähnt",
"notification_prefs_no_email": "Bitte trage zuerst eine E-Mail-Adresse ein, um Benachrichtigungen zu erhalten.",
"notification_unread": "ungelesen",
"mention_btn_label": "Person erwähnen",
"mention_popup_empty": "Keine Nutzer gefunden",
"page_title_home": "Archiv",
"page_title_persons": "Personen",
"page_title_admin": "Administration",
"page_title_login": "Anmelden",
"page_title_error": "Fehler Familienarchiv",
"dashboard_notifications_heading": "Benachrichtigungen",
"dashboard_notification_mentioned": "erwähnt Sie",
"dashboard_notification_replied": "hat geantwortet",
"dashboard_needs_metadata_heading": "Metadaten fehlen",
"dashboard_needs_metadata_show_all": "Alle anzeigen",
"dashboard_recent_heading": "Zuletzt aktiv",
"dashboard_stats_documents": "Dokumente",
"dashboard_stats_persons": "Personen",
"dashboard_resume_label": "Zuletzt geöffnet:",
"dashboard_resume_fallback": "Unbekanntes Dokument",
"doc_status_placeholder": "Platzhalter",
"doc_status_uploaded": "Hochgeladen",
"doc_status_transcribed": "Transkribiert",
"doc_status_reviewed": "Geprüft",
"doc_status_archived": "Archiviert",
"doc_status_unknown": "Unbekannt",
"persons_stats_persons_one": "1 Person",
"persons_stats_persons_many": "{count} Personen",
"persons_stats_documents_one": "1 Dokument",
"persons_stats_documents_many": "{count} Dokumente",
"persons_stats_label_persons_one": "Person",
"persons_stats_label_persons_many": "Personen",
"persons_stats_label_documents_one": "Dokument",
"persons_stats_label_documents_many": "Dokumente",
"person_card_doc_count_one": "1 Dok.",
"person_card_doc_count_many": "{count} Dok.",
"error_person_not_found": "Die Person wurde nicht gefunden.",
"person_btn_edit": "Bearbeiten",
"person_discard_changes": "Änderungen verwerfen",
"person_danger_zone_heading": "Gefahrenzone",
"persons_new_birth_year": "Geburtsjahr",
"persons_new_death_year": "Todesjahr",
"persons_new_notes": "Notizen",
"person_save_changes": "Änderungen speichern",
"notification_view_all": "Alle anzeigen →",
"notification_history_heading": "Benachrichtigungen",
"notification_history_view_link": "Benachrichtigungsverlauf ansehen →",
"notification_filter_all": "Alle",
"notification_filter_unread": "Ungelesen",
"notification_filter_mention": "Erwähnung",
"notification_filter_reply": "Antwort",
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
"notification_load_more": "Ältere laden",
"notification_empty_history": "Keine Benachrichtigungen",
"notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.",
"notification_row_aria": "{actor} {type} auf \u201e{title}\u201c \u2014 {time} \u2014 {readState}",
"notification_read_state_read": "gelesen",
"notification_read_state_unread": "ungelesen"
"comment_start_discussion": "Diskussion starten →"
}

View File

@@ -16,7 +16,7 @@
"error_internal_error": "An unexpected error occurred.",
"nav_documents": "Documents",
"nav_persons": "Persons",
"nav_conversations": "Correspondence",
"nav_conversations": "Conversations",
"nav_admin": "Admin",
"nav_logout": "Sign out",
"btn_save": "Save",
@@ -120,41 +120,23 @@
"person_role_sender": "Sent",
"person_role_receiver": "Received",
"person_co_correspondents_heading": "Frequent correspondents",
"person_correspondents_hint": "click to view conversation",
"person_show_more": "+ {count} more",
"conv_heading": "Correspondence",
"conv_subtitle": "Browse a person's letters — with or without a correspondent.",
"conv_heading": "Conversations",
"conv_subtitle": "Follow the correspondence between two persons chronologically.",
"conv_label_person_a": "Person A (Sender)",
"conv_label_person_b": "Correspondent",
"conv_label_person_b": "Person B (Recipient)",
"conv_label_from": "Period from",
"conv_label_to": "Period to",
"conv_sort_label": "Sort:",
"conv_sort_newest": "Newest first",
"conv_sort_oldest": "Oldest first",
"conv_empty_heading": "Browse correspondence",
"conv_empty_text": "Choose a person from the archive to see their letters — with or without a correspondent.",
"conv_empty_heading": "Select two persons",
"conv_empty_text": "The correspondence will be shown here.",
"conv_no_results_heading": "No documents found.",
"conv_no_results_text": "Try adjusting the time period.",
"conv_swap_btn": "Swap persons",
"conv_summary": "{count} documents · {yearFrom}{yearTo}",
"conv_new_doc_link": "New document in this correspondence",
"conv_label_correspondent_optional": "Correspondent",
"conv_hint_single_person": "All letters from {name} — choose a correspondent above to narrow down",
"conv_hint_single_person_filtered": "All letters from {name} · {from}{to} · {sortLabel}",
"conv_strip_period": "Period",
"conv_strip_from_placeholder": "From…",
"conv_strip_to_placeholder": "To…",
"conv_strip_all_correspondents": "All correspondents",
"conv_strip_sort_newest": "Newest",
"conv_strip_sort_oldest": "Oldest",
"conv_suggestions_heading": "Top correspondents",
"conv_suggestions_all_label": "All correspondents of {name}",
"conv_letters_count": "{count} letters",
"conv_empty_search_placeholder": "Search person…",
"conv_empty_recent_label": "Recently opened",
"conv_asym_sent": "{count} from {name} →",
"conv_asym_received": "{count} from {name} ←",
"conv_no_party": "—",
"admin_heading": "Admin Dashboard",
"admin_tab_users": "Users",
"admin_tab_groups": "Groups",
@@ -172,14 +154,6 @@
"admin_multiselect_hint_full": "Ctrl+Click for multiple selection",
"admin_section_tags": "Tags",
"admin_tags_warning": "Warning: Renaming or deleting affects all linked documents.",
"admin_tags_list_title": "All Tags",
"admin_tags_empty": "No tags found.",
"admin_tags_select_prompt": "Select a tag from the list.",
"admin_tag_edit_heading": "Tag: {name}",
"admin_tag_updated": "Tag renamed.",
"admin_unsaved_warning": "You have unsaved changes — save or discard before switching.",
"admin_btn_collapse_list": "Collapse list",
"admin_btn_expand_list": "Expand list",
"admin_btn_edit_tag_label": "Edit tag",
"admin_tag_delete_confirm": "Really delete? The tag will be removed from all documents.",
"admin_btn_delete_tag_label": "Delete tag",
@@ -192,28 +166,6 @@
"admin_group_name_placeholder": "Group name (e.g. Editors)",
"admin_user_delete_confirm": "Really delete user {username}?",
"admin_btn_new_user": "New User",
"admin_users_list_title": "All Users",
"admin_users_search_placeholder": "Search users\u2026",
"admin_users_empty": "No users found.",
"admin_users_select_prompt": "Select a user from the list.",
"admin_btn_new_group": "New Group",
"admin_groups_list_title": "All Groups",
"admin_groups_empty": "No groups found.",
"admin_groups_select_prompt": "Select a group from the list.",
"admin_groups_permission_count": "{count} permissions",
"admin_group_new_heading": "Create new group",
"admin_group_edit_heading": "Group: {name}",
"admin_group_updated": "Group saved.",
"admin_group_created": "Group created.",
"admin_groups_section_standard": "Standard",
"admin_groups_section_administrative": "Administrative",
"admin_perm_read_all": "Read only",
"admin_perm_annotate_all": "Read & Annotate",
"admin_perm_write_all": "Read & Write",
"admin_perm_admin": "Full access (Admin)",
"admin_perm_admin_user": "Manage users",
"admin_perm_admin_tag": "Manage tags",
"admin_perm_admin_permission": "Manage permissions",
"admin_user_new_heading": "Create new user",
"admin_user_edit_heading": "Edit user: {username}",
"admin_user_created": "User has been created.",
@@ -223,12 +175,6 @@
"admin_label_initial_password": "Password",
"doc_file_error_preview": "Could not load preview.",
"doc_download_title": "Download",
"topbar_back_label": "Back to document list",
"topbar_more_actions": "More actions",
"topbar_overflow_more": "+{count} more",
"topbar_overflow_suffix": "more",
"topbar_overflow_heading": "More receivers",
"topbar_overflow_show": "Show {count} more receivers",
"doc_tag_filter_title": "Filter by {name}",
"doc_conversation_title": "Show conversation",
"doc_preview_iframe_title": "Document Preview",
@@ -303,14 +249,6 @@
"admin_system_backfill_hashes_description": "Computes the SHA-256 hash for all previously uploaded documents that do not have one yet. This ensures annotations are correctly linked to their file version and shown again.",
"admin_system_backfill_hashes_btn": "Compute file hashes",
"admin_system_backfill_hashes_success": "{count} documents were updated.",
"admin_system_import_heading": "Mass import",
"admin_system_import_description": "Imports documents and metadata from the spreadsheet file in the /import directory.",
"admin_system_import_btn_start": "Start import",
"admin_system_import_btn_retry": "Start again",
"admin_system_import_status_idle": "No import started.",
"admin_system_import_status_running": "Import running…",
"admin_system_import_status_done": "Import complete {count} documents processed.",
"admin_system_import_status_failed": "Error: {message}",
"comp_expandable_show_more": "Show more",
"comp_expandable_show_less": "Show less",
"error_comment_not_found": "The comment could not be found.",
@@ -327,7 +265,6 @@
"doc_panel_tab_history": "History",
"doc_panel_annotate": "Annotate",
"doc_panel_annotate_stop": "Done",
"doc_panel_annotate_hint": "Click and drag to mark an area",
"doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show annotations",
@@ -357,71 +294,5 @@
"enrich_done_body": "All documents have been processed.",
"enrich_back_to_list": "Back to list",
"comment_empty_hint": "No comments yet start the discussion!",
"comment_start_discussion": "Start discussion →",
"notification_bell_label": "Notifications",
"notification_bell_unread_label": "{count} unread notifications",
"notification_mark_all_read": "Mark all read",
"notification_empty": "No new notifications",
"notification_type_reply": "{actor} replied to your comment",
"notification_type_mention": "{actor} mentioned you in a comment",
"notification_prefs_heading": "Notifications",
"notification_pref_reply": "Email when someone replies to my comment",
"notification_pref_mention": "Email when someone mentions me in a comment",
"notification_prefs_no_email": "Please add an email address above to receive notifications.",
"notification_unread": "unread",
"mention_btn_label": "Mention person",
"mention_popup_empty": "No users found",
"page_title_home": "Archive",
"page_title_persons": "Persons",
"page_title_admin": "Administration",
"page_title_login": "Sign in",
"page_title_error": "Error Family Archive",
"dashboard_notifications_heading": "Notifications",
"dashboard_notification_mentioned": "mentioned you",
"dashboard_notification_replied": "replied",
"dashboard_needs_metadata_heading": "Missing Metadata",
"dashboard_needs_metadata_show_all": "Show all",
"dashboard_recent_heading": "Recent Activity",
"dashboard_stats_documents": "Documents",
"dashboard_stats_persons": "Persons",
"dashboard_resume_label": "Last opened:",
"dashboard_resume_fallback": "Unknown document",
"doc_status_placeholder": "Placeholder",
"doc_status_uploaded": "Uploaded",
"doc_status_transcribed": "Transcribed",
"doc_status_reviewed": "Reviewed",
"doc_status_archived": "Archived",
"doc_status_unknown": "Unknown",
"persons_stats_persons_one": "1 person",
"persons_stats_persons_many": "{count} persons",
"persons_stats_documents_one": "1 document",
"persons_stats_documents_many": "{count} documents",
"persons_stats_label_persons_one": "Person",
"persons_stats_label_persons_many": "Persons",
"persons_stats_label_documents_one": "Document",
"persons_stats_label_documents_many": "Documents",
"person_card_doc_count_one": "1 doc",
"person_card_doc_count_many": "{count} docs",
"error_person_not_found": "Person not found.",
"person_btn_edit": "Edit",
"person_discard_changes": "Discard changes",
"person_danger_zone_heading": "Danger zone",
"persons_new_birth_year": "Birth year",
"persons_new_death_year": "Death year",
"persons_new_notes": "Notes",
"person_save_changes": "Save changes",
"notification_view_all": "View all →",
"notification_history_heading": "Notifications",
"notification_history_view_link": "View notification history →",
"notification_filter_all": "All",
"notification_filter_unread": "Unread",
"notification_filter_mention": "Mention",
"notification_filter_reply": "Reply",
"notification_mark_all_read_aria": "Mark all notifications as read",
"notification_load_more": "Load older",
"notification_empty_history": "No notifications",
"notification_empty_history_body": "Mentions and replies to your comments will appear here.",
"notification_row_aria": "{actor} {type} on \"{title}\" — {time} — {readState}",
"notification_read_state_read": "read",
"notification_read_state_unread": "unread"
"comment_start_discussion": "Start discussion →"
}

View File

@@ -16,7 +16,7 @@
"error_internal_error": "Se ha producido un error inesperado.",
"nav_documents": "Documentos",
"nav_persons": "Personas",
"nav_conversations": "Correspondencia",
"nav_conversations": "Conversaciones",
"nav_admin": "Admin",
"nav_logout": "Cerrar sesión",
"btn_save": "Guardar",
@@ -120,41 +120,23 @@
"person_role_sender": "Enviado",
"person_role_receiver": "Recibido",
"person_co_correspondents_heading": "Corresponsales frecuentes",
"person_correspondents_hint": "clic para ver conversación",
"person_show_more": "+ {count} más",
"conv_heading": "Correspondencia",
"conv_subtitle": "Explore las cartas de una persona con o sin corresponsal.",
"conv_heading": "Conversaciones",
"conv_subtitle": "Siga la correspondencia entre dos personas cronológicamente.",
"conv_label_person_a": "Persona A (Remitente)",
"conv_label_person_b": "Corresponsal",
"conv_label_person_b": "Persona B (Destinatario)",
"conv_label_from": "Período desde",
"conv_label_to": "Período hasta",
"conv_sort_label": "Ordenar:",
"conv_sort_newest": "Más reciente primero",
"conv_sort_oldest": "Más antiguo primero",
"conv_empty_heading": "Explorar correspondencia",
"conv_empty_text": "Elige una persona del archivo para ver sus cartas — con o sin corresponsal.",
"conv_empty_heading": "Seleccione dos personas",
"conv_empty_text": "La correspondencia se mostrará aquí.",
"conv_no_results_heading": "No se encontraron documentos.",
"conv_no_results_text": "Intente ajustar el período de tiempo.",
"conv_swap_btn": "Intercambiar personas",
"conv_summary": "{count} documentos · {yearFrom}{yearTo}",
"conv_new_doc_link": "Nuevo documento en esta correspondencia",
"conv_label_correspondent_optional": "Corresponsal",
"conv_hint_single_person": "Todas las cartas de {name} — elige un corresponsal arriba para filtrar",
"conv_hint_single_person_filtered": "Todas las cartas de {name} · {from}{to} · {sortLabel}",
"conv_strip_period": "Período",
"conv_strip_from_placeholder": "Desde…",
"conv_strip_to_placeholder": "Hasta…",
"conv_strip_all_correspondents": "Todos los corresponsales",
"conv_strip_sort_newest": "Más reciente",
"conv_strip_sort_oldest": "Más antiguo",
"conv_suggestions_heading": "Corresponsales frecuentes",
"conv_suggestions_all_label": "Todos los corresponsales de {name}",
"conv_letters_count": "{count} cartas",
"conv_empty_search_placeholder": "Buscar persona…",
"conv_empty_recent_label": "Recientemente abiertos",
"conv_asym_sent": "{count} de {name} →",
"conv_asym_received": "{count} de {name} ←",
"conv_no_party": "—",
"admin_heading": "Panel de administración",
"admin_tab_users": "Usuarios",
"admin_tab_groups": "Grupos",
@@ -172,14 +154,6 @@
"admin_multiselect_hint_full": "Ctrl+Clic para selección múltiple",
"admin_section_tags": "Etiquetas",
"admin_tags_warning": "Advertencia: Renombrar o eliminar afecta a todos los documentos vinculados.",
"admin_tags_list_title": "Todas las etiquetas",
"admin_tags_empty": "No hay etiquetas.",
"admin_tags_select_prompt": "Selecciona una etiqueta de la lista.",
"admin_tag_edit_heading": "Etiqueta: {name}",
"admin_tag_updated": "Etiqueta renombrada.",
"admin_unsaved_warning": "Tienes cambios sin guardar — guarda o descarta antes de cambiar.",
"admin_btn_collapse_list": "Contraer lista",
"admin_btn_expand_list": "Expandir lista",
"admin_btn_edit_tag_label": "Editar etiqueta",
"admin_tag_delete_confirm": "¿Realmente eliminar? La etiqueta se eliminará de todos los documentos.",
"admin_btn_delete_tag_label": "Eliminar etiqueta",
@@ -192,28 +166,6 @@
"admin_group_name_placeholder": "Nombre del grupo (p.ej. Editores)",
"admin_user_delete_confirm": "¿Realmente eliminar al usuario {username}?",
"admin_btn_new_user": "Nuevo usuario",
"admin_users_list_title": "Todos los usuarios",
"admin_users_search_placeholder": "Buscar usuarios\u2026",
"admin_users_empty": "No hay usuarios.",
"admin_users_select_prompt": "Selecciona un usuario de la lista.",
"admin_btn_new_group": "Nuevo grupo",
"admin_groups_list_title": "Todos los grupos",
"admin_groups_empty": "No hay grupos.",
"admin_groups_select_prompt": "Selecciona un grupo de la lista.",
"admin_groups_permission_count": "{count} permisos",
"admin_group_new_heading": "Crear nuevo grupo",
"admin_group_edit_heading": "Grupo: {name}",
"admin_group_updated": "Grupo guardado.",
"admin_group_created": "Grupo creado.",
"admin_groups_section_standard": "Est\u00e1ndar",
"admin_groups_section_administrative": "Administrativo",
"admin_perm_read_all": "Solo lectura",
"admin_perm_annotate_all": "Leer y anotar",
"admin_perm_write_all": "Leer y escribir",
"admin_perm_admin": "Acceso completo (Admin)",
"admin_perm_admin_user": "Gestionar usuarios",
"admin_perm_admin_tag": "Gestionar etiquetas",
"admin_perm_admin_permission": "Gestionar permisos",
"admin_user_new_heading": "Crear nuevo usuario",
"admin_user_edit_heading": "Editar usuario: {username}",
"admin_user_created": "Usuario creado.",
@@ -223,12 +175,6 @@
"admin_label_initial_password": "Contraseña",
"doc_file_error_preview": "No se pudo cargar la vista previa.",
"doc_download_title": "Descargar",
"topbar_back_label": "Volver a la lista de documentos",
"topbar_more_actions": "Más acciones",
"topbar_overflow_more": "+{count} más",
"topbar_overflow_suffix": "más",
"topbar_overflow_heading": "Más destinatarios",
"topbar_overflow_show": "Mostrar {count} destinatarios más",
"doc_tag_filter_title": "Filtrar por {name}",
"doc_conversation_title": "Ver conversación",
"doc_preview_iframe_title": "Vista previa del documento",
@@ -303,14 +249,6 @@
"admin_system_backfill_hashes_description": "Calcula el hash SHA-256 para todos los documentos ya subidos que aún no tienen uno. Así las anotaciones se vinculan correctamente a su versión del archivo y vuelven a mostrarse.",
"admin_system_backfill_hashes_btn": "Calcular hashes de archivo",
"admin_system_backfill_hashes_success": "{count} documentos fueron actualizados.",
"admin_system_import_heading": "Importación masiva",
"admin_system_import_description": "Importa documentos y metadatos desde el archivo en el directorio /import.",
"admin_system_import_btn_start": "Iniciar importación",
"admin_system_import_btn_retry": "Iniciar de nuevo",
"admin_system_import_status_idle": "No hay importación iniciada.",
"admin_system_import_status_running": "Importación en curso…",
"admin_system_import_status_done": "Importación completada {count} documentos procesados.",
"admin_system_import_status_failed": "Error: {message}",
"comp_expandable_show_more": "Mostrar más",
"comp_expandable_show_less": "Mostrar menos",
"error_comment_not_found": "El comentario no pudo encontrarse.",
@@ -327,7 +265,6 @@
"doc_panel_tab_history": "Historial",
"doc_panel_annotate": "Anotar",
"doc_panel_annotate_stop": "Listo",
"doc_panel_annotate_hint": "Haga clic y arrastre para marcar un área",
"doc_panel_annotation_thread_title": "Anotación",
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar anotaciones",
@@ -357,71 +294,5 @@
"enrich_done_body": "Todos los documentos han sido procesados.",
"enrich_back_to_list": "Volver a la lista",
"comment_empty_hint": "Aún no hay comentarios ¡inicia la discusión!",
"comment_start_discussion": "Iniciar discusión →",
"notification_bell_label": "Notificaciones",
"notification_bell_unread_label": "{count} notificaciones sin leer",
"notification_mark_all_read": "Marcar todo como leído",
"notification_empty": "No hay notificaciones nuevas",
"notification_type_reply": "{actor} respondió a tu comentario",
"notification_type_mention": "{actor} te mencionó en un comentario",
"notification_prefs_heading": "Notificaciones",
"notification_pref_reply": "Correo cuando alguien responde a mi comentario",
"notification_pref_mention": "Correo cuando alguien me menciona en un comentario",
"notification_prefs_no_email": "Por favor, añade una dirección de correo electrónico para recibir notificaciones.",
"notification_unread": "no leído",
"mention_btn_label": "Mencionar persona",
"mention_popup_empty": "No se encontraron usuarios",
"page_title_home": "Archivo",
"page_title_persons": "Personas",
"page_title_admin": "Administración",
"page_title_login": "Iniciar sesión",
"page_title_error": "Error Archivo familiar",
"dashboard_notifications_heading": "Notificaciones",
"dashboard_notification_mentioned": "te mencionó",
"dashboard_notification_replied": "respondió",
"dashboard_needs_metadata_heading": "Metadatos incompletos",
"dashboard_needs_metadata_show_all": "Ver todos",
"dashboard_recent_heading": "Actividad reciente",
"dashboard_stats_documents": "Documentos",
"dashboard_stats_persons": "Personas",
"dashboard_resume_label": "Último abierto:",
"dashboard_resume_fallback": "Documento desconocido",
"doc_status_placeholder": "Marcador",
"doc_status_uploaded": "Cargado",
"doc_status_transcribed": "Transcrito",
"doc_status_reviewed": "Revisado",
"doc_status_archived": "Archivado",
"doc_status_unknown": "Desconocido",
"persons_stats_persons_one": "1 persona",
"persons_stats_persons_many": "{count} personas",
"persons_stats_documents_one": "1 documento",
"persons_stats_documents_many": "{count} documentos",
"persons_stats_label_persons_one": "Persona",
"persons_stats_label_persons_many": "Personas",
"persons_stats_label_documents_one": "Documento",
"persons_stats_label_documents_many": "Documentos",
"person_card_doc_count_one": "1 doc.",
"person_card_doc_count_many": "{count} docs.",
"error_person_not_found": "Persona no encontrada.",
"person_btn_edit": "Editar",
"person_discard_changes": "Descartar cambios",
"person_danger_zone_heading": "Zona de peligro",
"persons_new_birth_year": "Año de nacimiento",
"persons_new_death_year": "Año de fallecimiento",
"persons_new_notes": "Notas",
"person_save_changes": "Guardar cambios",
"notification_view_all": "Ver todas →",
"notification_history_heading": "Notificaciones",
"notification_history_view_link": "Ver historial de notificaciones →",
"notification_filter_all": "Todas",
"notification_filter_unread": "No leídas",
"notification_filter_mention": "Mención",
"notification_filter_reply": "Respuesta",
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
"notification_load_more": "Cargar anteriores",
"notification_empty_history": "Sin notificaciones",
"notification_empty_history_body": "Aquí aparecerán las menciones y respuestas a tus comentarios.",
"notification_row_aria": "{actor} {type} en \"{title}\" — {time} — {readState}",
"notification_read_state_read": "leído",
"notification_read_state_unread": "no leído"
"comment_start_discussion": "Iniciar discusión →"
}

View File

@@ -13,7 +13,6 @@
"pdfjs-dist": "^5.5.207"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@inlang/paraglide-js": "^2.5.0",
@@ -27,7 +26,6 @@
"@types/diff": "^7.0.2",
"@types/node": "^24",
"@vitest/browser-playwright": "^4.0.10",
"@vitest/coverage-v8": "^4.1.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
@@ -48,19 +46,6 @@
"vitest-browser-svelte": "^2.0.1"
}
},
"node_modules/@axe-core/playwright": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
"integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.11.1"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -76,16 +61,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
@@ -96,46 +71,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@blazediff/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
@@ -2587,37 +2522,6 @@
}
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz",
"integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.0",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.0",
"vitest": "4.1.0"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
@@ -2851,45 +2755,6 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
"integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/axe-core": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
"integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -3684,13 +3549,6 @@
"node": ">= 0.4"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -3828,45 +3686,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -4310,34 +4129,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",

View File

@@ -14,7 +14,6 @@
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"test:coverage": "vitest run --coverage --project=server",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
@@ -26,7 +25,6 @@
"pdfjs-dist": "^5.5.207"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@inlang/paraglide-js": "^2.5.0",
@@ -40,7 +38,6 @@
"@types/diff": "^7.0.2",
"@types/node": "^24",
"@vitest/browser-playwright": "^4.0.10",
"@vitest/coverage-v8": "^4.1.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",

View File

@@ -1,6 +0,0 @@
{
"branch": "feature/68-new-document-file-first",
"commitSha": "53b482c5f24688ca3426d434c832e8d24acfee4c",
"startedAt": "2026-03-27T10:49:23.106Z",
"description": null
}

View File

@@ -1,6 +0,0 @@
{
"branch": "feature/68-new-document-file-first",
"commitSha": "53b482c5f24688ca3426d434c832e8d24acfee4c",
"startedAt": "2026-03-27T10:49:39.204Z",
"description": null
}

View File

@@ -1,6 +0,0 @@
{
"branch": "feature/68-new-document-file-first",
"commitSha": "53b482c5f24688ca3426d434c832e8d24acfee4c",
"startedAt": "2026-03-27T10:51:02.177Z",
"description": null
}

View File

@@ -12,7 +12,6 @@ declare global {
email?: string;
contact?: string;
groups: {
id: string;
name: string;
permissions: string[];
}[];

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