Compare commits
153 Commits
feature/56
...
7eda0aefcc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eda0aefcc | ||
|
|
3e76ef5281 | ||
|
|
2171c3702a | ||
|
|
6976daa910 | ||
|
|
dc487e2f97 | ||
|
|
698a0fb15e | ||
|
|
a7b0bd96d4 | ||
|
|
7734ce7bae | ||
|
|
c8da2224f8 | ||
|
|
08f3f92167 | ||
|
|
1a849362a1 | ||
|
|
b948c9a46c | ||
|
|
df79eec5cc | ||
|
|
1d08522df8 | ||
|
|
2ce95f2542 | ||
|
|
49f71e32ff | ||
|
|
0610f0ee0f | ||
|
|
4aa3855936 | ||
|
|
0003b6d6ef | ||
|
|
147d1f2de5 | ||
|
|
968993c48e | ||
|
|
304359f67d | ||
|
|
bf46fe6d8b | ||
|
|
06fbb2fe81 | ||
|
|
3dd0ff94c6 | ||
|
|
a81959a591 | ||
|
|
d663ba87b0 | ||
|
|
0cc79cd0fd | ||
|
|
16101240f1 | ||
|
|
e28cd03953 | ||
|
|
b5580b0b24 | ||
|
|
4c3d253066 | ||
|
|
e7829312e8 | ||
|
|
2b0f467213 | ||
|
|
9a4e088de9 | ||
|
|
f9236cc575 | ||
|
|
e27af75e21 | ||
|
|
3983771e79 | ||
|
|
25d6ce4711 | ||
|
|
4820360e40 | ||
|
|
2fb5e4d17a | ||
|
|
29f81f48db | ||
|
|
070153a71d | ||
|
|
affee407ef | ||
|
|
4ff87b035e | ||
|
|
f568c0aeb7 | ||
|
|
9900d0b54b | ||
|
|
9ae6186e66 | ||
|
|
c21e19a15c | ||
|
|
7825c7749a | ||
|
|
d13422c65a | ||
|
|
23d0005514 | ||
|
|
dc6ea080c4 | ||
|
|
2bc3b3fb6c | ||
|
|
55cf1fb0a4 | ||
|
|
e455efa670 | ||
|
|
1615a4ffa5 | ||
|
|
bc62f3b0af | ||
|
|
420f50b6d5 | ||
|
|
d91a10ef8e | ||
|
|
44f495ca8b | ||
|
|
74bf49552b | ||
|
|
1de4f8a605 | ||
|
|
f8d888a5be | ||
|
|
29f0ec8a05 | ||
|
|
5db17880f9 | ||
|
|
ce02c1bf39 | ||
|
|
e1c09ddc7f | ||
|
|
93408c5825 | ||
|
|
2a2ce240e1 | ||
|
|
0bd7a70c96 | ||
|
|
a570dff4e9 | ||
|
|
fcff7fbdb1 | ||
|
|
5cf6947040 | ||
|
|
d053f6dc40 | ||
|
|
afebaf4c53 | ||
|
|
1bfe0ab022 | ||
|
|
6ebae19984 | ||
|
|
fa9577052d | ||
|
|
a7eaa40852 | ||
|
|
c5e28ac18e | ||
|
|
d6f4ea05d9 | ||
|
|
065dd8fabd | ||
|
|
a967483cd9 | ||
|
|
5d0a2a2c9c | ||
|
|
0f0d74eb2f | ||
|
|
20f6de4424 | ||
|
|
bf82ebfe1d | ||
|
|
c6984e49ee | ||
|
|
150bc2f171 | ||
|
|
41c311249b | ||
|
|
2efa790243 | ||
|
|
648bdffe4f | ||
|
|
99e3163c0e | ||
|
|
f0940524e7 | ||
|
|
a302f96560 | ||
|
|
654e736f8a | ||
|
|
078bc1c886 | ||
|
|
8555193a79 | ||
|
|
aab9e9a4b0 | ||
|
|
0ce18e1eed | ||
|
|
2bfbf45eba | ||
|
|
40f01a7712 | ||
|
|
0db68da00c | ||
|
|
e831de4f85 | ||
|
|
90e94b350a | ||
|
|
1facf9cd60 | ||
|
|
25014cce2d | ||
|
|
6f71682454 | ||
|
|
af59ed4de4 | ||
|
|
d46764ef4f | ||
|
|
d40d4b21e1 | ||
|
|
1ea84e4dc8 | ||
|
|
d078ad8224 | ||
|
|
9d5c57b49b | ||
|
|
0795e4099f | ||
|
|
1413058ae7 | ||
|
|
91a29d501d | ||
|
|
963807ff05 | ||
|
|
6a663cefe6 | ||
|
|
db103ca1ab | ||
|
|
3ec680b812 | ||
|
|
50e3f948c7 | ||
|
|
bbfef9a22d | ||
|
|
332b5b3c40 | ||
|
|
29a71f4421 | ||
|
|
eade2aa48a | ||
|
|
bda3cdf9af | ||
|
|
1765ffce01 | ||
|
|
399fa36f60 | ||
|
|
51a0eb76de | ||
|
|
162c58e8c5 | ||
|
|
e4539ed0f0 | ||
|
|
caba89dacc | ||
|
|
e83ba9b681 | ||
|
|
93befbd8da | ||
|
|
9aa98b4fb6 | ||
|
|
dd360ade8b | ||
|
|
f71712ab4b | ||
|
|
10783fdb55 | ||
|
|
5ea5590c89 | ||
|
|
142f296255 | ||
|
|
c19f7b3b1a | ||
|
|
db9d8ed457 | ||
|
|
65457a5650 | ||
|
|
1eb2659ba0 | ||
|
|
f18649fb79 | ||
|
|
a392e85f43 | ||
|
|
c9b4e6dad4 | ||
|
|
8519fbb48a | ||
|
|
ee85ce4668 | ||
|
|
ecfd80bf9a | ||
|
|
8c2bdbd777 |
@@ -65,6 +65,16 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-jetty</artifactId>
|
<artifactId>spring-boot-starter-jetty</artifactId>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-actuator-test</artifactId>
|
<artifactId>spring-boot-starter-actuator-test</artifactId>
|
||||||
@@ -161,6 +171,50 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<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>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ public class DataInitializer {
|
|||||||
TagRepository tagRepo,
|
TagRepository tagRepo,
|
||||||
PasswordEncoder passwordEncoder) {
|
PasswordEncoder passwordEncoder) {
|
||||||
return args -> {
|
return args -> {
|
||||||
|
// Always reset the admin password to the configured value so a failed password-reset
|
||||||
|
// test from a previous run can never leave the account locked out.
|
||||||
|
userRepository.findByUsername(adminUsername).ifPresent(admin -> {
|
||||||
|
admin.setPassword(passwordEncoder.encode(adminPassword));
|
||||||
|
userRepository.save(admin);
|
||||||
|
log.info("E2E seed: Admin-Passwort auf konfigurierten Wert zurückgesetzt.");
|
||||||
|
});
|
||||||
|
|
||||||
// Always ensure the read-only test user exists, even when seed data was already loaded.
|
// Always ensure the read-only test user exists, even when seed data was already loaded.
|
||||||
if (userRepository.findByUsername("reader").isEmpty()) {
|
if (userRepository.findByUsername("reader").isEmpty()) {
|
||||||
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
|
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public class AnnotationController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentAnnotation createAnnotation(
|
public DocumentAnnotation createAnnotation(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@RequestBody CreateAnnotationDTO dto,
|
@RequestBody CreateAnnotationDTO dto,
|
||||||
@@ -47,7 +47,7 @@ public class AnnotationController {
|
|||||||
|
|
||||||
@DeleteMapping("/{annotationId}")
|
@DeleteMapping("/{annotationId}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public void deleteAnnotation(
|
public void deleteAnnotation(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID annotationId,
|
@PathVariable UUID annotationId,
|
||||||
|
|||||||
@@ -33,25 +33,25 @@ public class CommentController {
|
|||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/comments")
|
@PostMapping("/api/documents/{documentId}/comments")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentComment postDocumentComment(
|
public DocumentComment postDocumentComment(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.postComment(documentId, null, dto.getContent(), author);
|
return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentComment replyToDocumentComment(
|
public DocumentComment replyToDocumentComment(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID commentId,
|
@PathVariable UUID commentId,
|
||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Annotation comments ──────────────────────────────────────────────────
|
// ─── Annotation comments ──────────────────────────────────────────────────
|
||||||
@@ -63,32 +63,32 @@ public class CommentController {
|
|||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentComment postAnnotationComment(
|
public DocumentComment postAnnotationComment(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID annotationId,
|
@PathVariable UUID annotationId,
|
||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.postComment(documentId, annotationId, dto.getContent(), author);
|
return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentComment replyToAnnotationComment(
|
public DocumentComment replyToAnnotationComment(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID commentId,
|
@PathVariable UUID commentId,
|
||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Edit and delete (shared) ─────────────────────────────────────────────
|
// ─── Edit and delete (shared) ─────────────────────────────────────────────
|
||||||
|
|
||||||
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
|
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentComment editComment(
|
public DocumentComment editComment(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID commentId,
|
@PathVariable UUID commentId,
|
||||||
|
|||||||
@@ -2,16 +2,23 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
@@ -23,6 +30,7 @@ import org.springframework.http.HttpHeaders;
|
|||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.core.io.InputStreamResource;
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@@ -103,6 +111,80 @@ public class DocumentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- DELETE ---
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
|
||||||
|
documentService.deleteDocument(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- QUICK UPLOAD ---
|
||||||
|
|
||||||
|
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||||
|
"application/pdf", "image/jpeg", "image/png", "image/tiff");
|
||||||
|
|
||||||
|
public record UploadError(String filename, String code) {}
|
||||||
|
public record QuickUploadResult(List<Document> created, List<Document> updated, List<UploadError> errors) {}
|
||||||
|
|
||||||
|
@PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public QuickUploadResult quickUpload(
|
||||||
|
@RequestPart(value = "files", required = false) List<MultipartFile> files) {
|
||||||
|
List<Document> created = new ArrayList<>();
|
||||||
|
List<Document> updated = new ArrayList<>();
|
||||||
|
List<UploadError> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
if (files == null || files.isEmpty()) {
|
||||||
|
return new QuickUploadResult(created, updated, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (MultipartFile file : files) {
|
||||||
|
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||||
|
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
DocumentService.StoreResult result = documentService.storeDocument(file);
|
||||||
|
if (result.isNew()) {
|
||||||
|
created.add(result.document());
|
||||||
|
} else {
|
||||||
|
updated.add(result.document());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
errors.add(new UploadError(file.getOriginalFilename(), "FILE_UPLOAD_FAILED"));
|
||||||
|
log.warn("Quick upload failed for file {}: {}", file.getOriginalFilename(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new QuickUploadResult(created, updated, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/incomplete-count")
|
||||||
|
public Map<String, Long> getIncompleteCount() {
|
||||||
|
return Map.of("count", documentService.getIncompleteCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/incomplete")
|
||||||
|
public List<IncompleteDocumentDTO> getIncomplete(
|
||||||
|
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) {
|
||||||
|
return documentService.findIncompleteDocuments(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/incomplete/next")
|
||||||
|
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
||||||
|
return documentService.findNextIncompleteDocument(excludeId)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.noContent().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recent-activity")
|
||||||
|
public ResponseEntity<List<Document>> getRecentActivity(
|
||||||
|
@RequestParam(defaultValue = "5") int size) {
|
||||||
|
return ResponseEntity.ok(documentService.getRecentActivity(size));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
public ResponseEntity<List<Document>> search(
|
public ResponseEntity<List<Document>> search(
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
@@ -110,8 +192,9 @@ public class DocumentController {
|
|||||||
@RequestParam(required = false) LocalDate to,
|
@RequestParam(required = false) LocalDate to,
|
||||||
@RequestParam(required = false) UUID senderId,
|
@RequestParam(required = false) UUID senderId,
|
||||||
@RequestParam(required = false) UUID receiverId,
|
@RequestParam(required = false) UUID receiverId,
|
||||||
@RequestParam(required = false, name = "tag") List<String> tags) {
|
@RequestParam(required = false, name = "tag") List<String> tags,
|
||||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags));
|
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status) {
|
||||||
|
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- VERSIONS ---
|
// --- VERSIONS ---
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
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;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@@ -30,6 +32,18 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
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)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
||||||
log.error("Unhandled exception", ex);
|
log.error("Unhandled exception", ex);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
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.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
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") int size,
|
||||||
|
@RequestParam(required = false) NotificationType type,
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@ package org.raddatz.familienarchiv.dto;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class CreateCommentDTO {
|
public class CreateCommentDTO {
|
||||||
private String content;
|
private String content;
|
||||||
|
private List<UUID> mentionedUserIds = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ public class DocumentUpdateDTO {
|
|||||||
private UUID senderId;
|
private UUID senderId;
|
||||||
private List<UUID> receiverIds;
|
private List<UUID> receiverIds;
|
||||||
private String tags;
|
private String tags;
|
||||||
|
private Boolean metadataComplete;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
public record NotificationPreferenceDTO(boolean notifyOnReply, boolean notifyOnMention) {}
|
||||||
@@ -17,6 +17,8 @@ public enum ErrorCode {
|
|||||||
FILE_NOT_FOUND,
|
FILE_NOT_FOUND,
|
||||||
/** An error occurred while uploading a file to object storage. 500 */
|
/** An error occurred while uploading a file to object storage. 500 */
|
||||||
FILE_UPLOAD_FAILED,
|
FILE_UPLOAD_FAILED,
|
||||||
|
/** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */
|
||||||
|
UNSUPPORTED_FILE_TYPE,
|
||||||
|
|
||||||
// --- Users ---
|
// --- Users ---
|
||||||
/** A user with the given ID or username does not exist. 404 */
|
/** A user with the given ID or username does not exist. 404 */
|
||||||
@@ -48,6 +50,10 @@ public enum ErrorCode {
|
|||||||
/** The comment with the given ID does not exist. 404 */
|
/** The comment with the given ID does not exist. 404 */
|
||||||
COMMENT_NOT_FOUND,
|
COMMENT_NOT_FOUND,
|
||||||
|
|
||||||
|
// --- Notifications ---
|
||||||
|
/** The notification with the given ID does not exist. 404 */
|
||||||
|
NOTIFICATION_NOT_FOUND,
|
||||||
|
|
||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ public class AppUser {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen
|
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
|
// Ein User kann in mehreren Gruppen sein
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))
|
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ public class Document {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column(name = "metadata_complete", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean metadataComplete = false;
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
import org.raddatz.familienarchiv.dto.MentionDTO;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -60,4 +62,21 @@ public class DocumentComment {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private List<DocumentComment> replies = new ArrayList<>();
|
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<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
public enum NotificationType {
|
||||||
|
REPLY,
|
||||||
|
MENTION
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package org.raddatz.familienarchiv.repository;
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
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.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -12,4 +15,9 @@ import java.util.UUID;
|
|||||||
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
|
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
|
||||||
Optional<AppUser> findByUsername(String username);
|
Optional<AppUser> findByUsername(String username);
|
||||||
Optional<AppUser> findByEmail(String email);
|
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);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@ package org.raddatz.familienarchiv.repository;
|
|||||||
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
@@ -21,6 +23,9 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||||
|
|
||||||
|
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
|
||||||
|
Optional<Document> findFirstByOriginalFilename(String originalFilename);
|
||||||
|
|
||||||
// Findet alle Dokumente mit einem bestimmten Status
|
// Findet alle Dokumente mit einem bestimmten Status
|
||||||
// z.B. um alle offenen "PLACEHOLDER" zu finden
|
// z.B. um alle offenen "PLACEHOLDER" zu finden
|
||||||
List<Document> findByStatus(DocumentStatus status);
|
List<Document> findByStatus(DocumentStatus status);
|
||||||
@@ -39,6 +44,14 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||||
|
|
||||||
|
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 " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"JOIN d.receivers r " +
|
"JOIN d.receivers r " +
|
||||||
"WHERE " +
|
"WHERE " +
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import java.util.List;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -55,6 +56,11 @@ 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)
|
// Filtert nach Schlagworten (UND-Verknüpfung)
|
||||||
public static Specification<Document> hasTags(List<String> tags) {
|
public static Specification<Document> hasTags(List<String> tags) {
|
||||||
return (root, query, cb) -> {
|
return (root, query, cb) -> {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
long countByRecipientIdAndReadFalse(UUID recipientId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId")
|
||||||
|
void markAllReadByRecipientId(@Param("userId") UUID userId);
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Lookup by full alias string, used during ODS mass import
|
// Lookup by full alias string, used during ODS mass import
|
||||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||||
|
|
||||||
|
// Exact first+last name match, used for filename-based sender lookup
|
||||||
|
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
||||||
|
|
||||||
// --- Correspondent queries ---
|
// --- Correspondent queries ---
|
||||||
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public class PermissionAspect {
|
|||||||
RequirePermission permission = getAnnotation(joinPoint);
|
RequirePermission permission = getAnnotation(joinPoint);
|
||||||
|
|
||||||
if (permission != null) {
|
if (permission != null) {
|
||||||
validateUserAccess(permission.value());
|
validateUserAccess(permission.value()); // value() is now Permission[]
|
||||||
}
|
}
|
||||||
|
|
||||||
return joinPoint.proceed();
|
return joinPoint.proceed();
|
||||||
@@ -43,18 +43,23 @@ public class PermissionAspect {
|
|||||||
return joinPoint.getTarget().getClass().getAnnotation(RequirePermission.class);
|
return joinPoint.getTarget().getClass().getAnnotation(RequirePermission.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateUserAccess(Permission requiredPerm) {
|
private void validateUserAccess(Permission[] requiredPerms) {
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
if (auth == null || !auth.isAuthenticated()) {
|
if (auth == null || !auth.isAuthenticated()) {
|
||||||
throw DomainException.unauthorized("Not authenticated");
|
throw DomainException.unauthorized("Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasPermission = auth.getAuthorities().stream()
|
boolean hasAny = auth.getAuthorities().stream()
|
||||||
.anyMatch(a -> a.getAuthority().equals(requiredPerm.name()));
|
.anyMatch(a -> {
|
||||||
|
for (Permission p : requiredPerms) {
|
||||||
|
if (a.getAuthority().equals(p.name())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
if (!hasPermission) {
|
if (!hasAny) {
|
||||||
throw DomainException.forbidden("Missing required permission: " + requiredPerm.name());
|
throw DomainException.forbidden("Missing required permission");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ import java.lang.annotation.Target;
|
|||||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
public @interface RequirePermission {
|
public @interface RequirePermission {
|
||||||
Permission value(); // e.g. "ADMIN" or "WRITE_ALL"
|
Permission[] value(); // one or more — user needs any of the listed permissions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.dto.MentionDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
@@ -9,7 +10,9 @@ import org.raddatz.familienarchiv.repository.CommentRepository;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -17,20 +20,23 @@ import java.util.UUID;
|
|||||||
public class CommentService {
|
public class CommentService {
|
||||||
|
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
|
private final UserService userService;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
||||||
List<DocumentComment> roots =
|
List<DocumentComment> roots =
|
||||||
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
||||||
return withReplies(roots);
|
return withRepliesAndMentions(roots);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
||||||
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
||||||
return withReplies(roots);
|
return withRepliesAndMentions(roots);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) {
|
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
|
||||||
|
List<UUID> mentionedUserIds, AppUser author) {
|
||||||
DocumentComment comment = DocumentComment.builder()
|
DocumentComment comment = DocumentComment.builder()
|
||||||
.documentId(documentId)
|
.documentId(documentId)
|
||||||
.annotationId(annotationId)
|
.annotationId(annotationId)
|
||||||
@@ -38,11 +44,16 @@ public class CommentService {
|
|||||||
.authorId(author.getId())
|
.authorId(author.getId())
|
||||||
.authorName(resolveAuthorName(author))
|
.authorName(resolveAuthorName(author))
|
||||||
.build();
|
.build();
|
||||||
return commentRepository.save(comment);
|
saveMentions(comment, mentionedUserIds);
|
||||||
|
DocumentComment saved = commentRepository.save(comment);
|
||||||
|
withMentionDTOs(saved);
|
||||||
|
notificationService.notifyMentions(mentionedUserIds, saved);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) {
|
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content,
|
||||||
|
List<UUID> mentionedUserIds, AppUser author) {
|
||||||
DocumentComment target = commentRepository.findById(commentId)
|
DocumentComment target = commentRepository.findById(commentId)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
||||||
@@ -60,7 +71,15 @@ public class CommentService {
|
|||||||
.authorId(author.getId())
|
.authorId(author.getId())
|
||||||
.authorName(resolveAuthorName(author))
|
.authorName(resolveAuthorName(author))
|
||||||
.build();
|
.build();
|
||||||
return commentRepository.save(reply);
|
saveMentions(reply, mentionedUserIds);
|
||||||
|
DocumentComment saved = commentRepository.save(reply);
|
||||||
|
withMentionDTOs(saved);
|
||||||
|
|
||||||
|
Set<UUID> participantIds = collectParticipantIds(root);
|
||||||
|
participantIds.remove(author.getId());
|
||||||
|
notificationService.notifyReply(saved, participantIds);
|
||||||
|
notificationService.notifyMentions(mentionedUserIds, saved);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -84,13 +103,45 @@ public class CommentService {
|
|||||||
commentRepository.delete(comment);
|
commentRepository.delete(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<DocumentComment> findReplies(UUID parentId) {
|
||||||
|
return commentRepository.findByParentId(parentId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private List<DocumentComment> withReplies(List<DocumentComment> roots) {
|
private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) {
|
||||||
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId())));
|
roots.forEach(root -> {
|
||||||
|
List<DocumentComment> replies = commentRepository.findByParentId(root.getId());
|
||||||
|
replies.forEach(this::withMentionDTOs);
|
||||||
|
root.setReplies(replies);
|
||||||
|
withMentionDTOs(root);
|
||||||
|
});
|
||||||
return roots;
|
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) {
|
private DocumentComment findComment(UUID documentId, UUID commentId) {
|
||||||
return commentRepository.findById(commentId)
|
return commentRepository.findById(commentId)
|
||||||
.filter(c -> documentId.equals(c.getDocumentId()))
|
.filter(c -> documentId.equals(c.getDocumentId()))
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -42,27 +44,38 @@ public class DocumentService {
|
|||||||
private final DocumentVersionService documentVersionService;
|
private final DocumentVersionService documentVersionService;
|
||||||
private final AnnotationService annotationService;
|
private final AnnotationService annotationService;
|
||||||
|
|
||||||
|
public record StoreResult(Document document, boolean isNew) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine Datei hoch.
|
* Lädt eine Datei hoch.
|
||||||
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
||||||
* - Wenn JA: Aktualisiert Status und verknüpft Datei.
|
* - Wenn JA: Aktualisiert Status und verknüpft Datei — isNew = false.
|
||||||
* - Wenn NEIN: Erstellt neuen Eintrag (wartet auf Metadaten).
|
* - Wenn NEIN: Erstellt neuen Eintrag — isNew = true.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public Document storeDocument(MultipartFile file) throws IOException {
|
public StoreResult storeDocument(MultipartFile file) throws IOException {
|
||||||
String originalFilename = file.getOriginalFilename();
|
String originalFilename = file.getOriginalFilename();
|
||||||
|
|
||||||
// 1. Check for existing record
|
// 1. Check for existing record (findFirst to survive duplicate filenames in the DB)
|
||||||
Optional<Document> existingDoc = documentRepository.findByOriginalFilename(originalFilename);
|
Optional<Document> existingDoc = documentRepository.findFirstByOriginalFilename(originalFilename);
|
||||||
|
boolean isNew = existingDoc.isEmpty();
|
||||||
Document document;
|
Document document;
|
||||||
|
|
||||||
if (existingDoc.isPresent()) {
|
if (existingDoc.isPresent()) {
|
||||||
document = existingDoc.get();
|
document = existingDoc.get();
|
||||||
} else {
|
} else {
|
||||||
|
// New uploads from the drop zone always start as incomplete
|
||||||
|
ParsedFilename parsed = parseFilenameData(originalFilename);
|
||||||
|
Person sender = (parsed != null)
|
||||||
|
? personService.findByName(parsed.firstName(), parsed.lastName()).orElse(null)
|
||||||
|
: null;
|
||||||
document = Document.builder()
|
document = Document.builder()
|
||||||
.originalFilename(originalFilename)
|
.originalFilename(originalFilename)
|
||||||
.title(originalFilename)
|
.title(parsed != null ? parsed.title() : stripExtension(originalFilename))
|
||||||
|
.documentDate(parsed != null ? parsed.date() : null)
|
||||||
|
.sender(sender)
|
||||||
.status(DocumentStatus.UPLOADED)
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.metadataComplete(false)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +90,7 @@ public class DocumentService {
|
|||||||
document.setStatus(DocumentStatus.UPLOADED);
|
document.setStatus(DocumentStatus.UPLOADED);
|
||||||
}
|
}
|
||||||
|
|
||||||
return documentRepository.save(document);
|
return new StoreResult(documentRepository.save(document), isNew);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -86,15 +99,31 @@ public class DocumentService {
|
|||||||
? file.getOriginalFilename()
|
? file.getOriginalFilename()
|
||||||
: (dto.getTitle() != null ? dto.getTitle() : "Unbenanntes Dokument");
|
: (dto.getTitle() != null ? dto.getTitle() : "Unbenanntes Dokument");
|
||||||
|
|
||||||
|
// If the caller explicitly sets metadataComplete, use it.
|
||||||
|
// Otherwise apply heuristic: complete if at least one key field is present.
|
||||||
|
boolean metadataComplete;
|
||||||
|
if (dto.getMetadataComplete() != null) {
|
||||||
|
metadataComplete = dto.getMetadataComplete();
|
||||||
|
} else {
|
||||||
|
metadataComplete = dto.getDocumentDate() != null
|
||||||
|
|| dto.getSenderId() != null
|
||||||
|
|| (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
String titleToUse = (dto.getTitle() != null && !dto.getTitle().isBlank())
|
||||||
|
? dto.getTitle()
|
||||||
|
: titleFromFilename(filename);
|
||||||
|
|
||||||
Document doc = Document.builder()
|
Document doc = Document.builder()
|
||||||
.originalFilename(filename)
|
.originalFilename(filename)
|
||||||
.title(dto.getTitle())
|
.title(titleToUse)
|
||||||
.documentDate(dto.getDocumentDate())
|
.documentDate(dto.getDocumentDate())
|
||||||
.location(dto.getLocation())
|
.location(dto.getLocation())
|
||||||
.documentLocation(dto.getDocumentLocation())
|
.documentLocation(dto.getDocumentLocation())
|
||||||
.transcription(dto.getTranscription())
|
.transcription(dto.getTranscription())
|
||||||
.summary(dto.getSummary())
|
.summary(dto.getSummary())
|
||||||
.status(DocumentStatus.PLACEHOLDER)
|
.status(DocumentStatus.PLACEHOLDER)
|
||||||
|
.metadataComplete(metadataComplete)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
doc = documentRepository.save(doc);
|
doc = documentRepository.save(doc);
|
||||||
@@ -173,6 +202,11 @@ public class DocumentService {
|
|||||||
doc.getReceivers().clear(); // Alle entfernen
|
doc.getReceivers().clear(); // Alle entfernen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3b. metadataComplete — only update when explicitly set in the DTO
|
||||||
|
if (dto.getMetadataComplete() != null) {
|
||||||
|
doc.setMetadataComplete(dto.getMetadataComplete());
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||||
if (newFile != null && !newFile.isEmpty()) {
|
if (newFile != null && !newFile.isEmpty()) {
|
||||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||||
@@ -226,16 +260,23 @@ public class DocumentService {
|
|||||||
return documentRepository.save(doc);
|
return documentRepository.save(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
|
||||||
|
public List<Document> getRecentActivity(int size) {
|
||||||
|
return documentRepository.findAll(Sort.by(Sort.Direction.DESC, "updatedAt"))
|
||||||
|
.stream().limit(size).toList();
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
// 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) {
|
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, DocumentStatus status) {
|
||||||
Specification<Document> spec = Specification.where(hasText(text))
|
Specification<Document> spec = Specification.where(hasText(text))
|
||||||
.and(isBetween(from, to))
|
.and(isBetween(from, to))
|
||||||
.and(hasSender(sender))
|
.and(hasSender(sender))
|
||||||
.and(hasReceiver(receiver))
|
.and(hasReceiver(receiver))
|
||||||
.and(hasTags(tags));
|
.and(hasTags(tags))
|
||||||
|
.and(hasStatus(status));
|
||||||
|
|
||||||
// Immer sortiert nach Datum
|
// Neueste zuerst (nach Erstellungsdatum)
|
||||||
return documentRepository.findAll(spec, Sort.by(Sort.Direction.ASC, "documentDate"));
|
return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. SPEZIALITÄT: Der Schriftwechsel
|
// 2. SPEZIALITÄT: Der Schriftwechsel
|
||||||
@@ -277,6 +318,31 @@ public class DocumentService {
|
|||||||
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getIncompleteCount() {
|
||||||
|
return documentRepository.countByMetadataCompleteFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<IncompleteDocumentDTO> findIncompleteDocuments(int size) {
|
||||||
|
PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
return documentRepository.findByMetadataCompleteFalse(pageable)
|
||||||
|
.stream()
|
||||||
|
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Document> findNextIncompleteDocument(UUID currentId) {
|
||||||
|
return documentRepository.findFirstByMetadataCompleteFalseAndIdNot(
|
||||||
|
currentId, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteDocument(UUID id) {
|
||||||
|
if (!documentRepository.existsById(id)) {
|
||||||
|
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
||||||
|
}
|
||||||
|
documentRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteTagCascading(UUID tagId) {
|
public void deleteTagCascading(UUID tagId) {
|
||||||
documentRepository.findByTags_Id(tagId).forEach(doc -> {
|
documentRepository.findByTags_Id(tagId).forEach(doc -> {
|
||||||
@@ -307,6 +373,87 @@ public class DocumentService {
|
|||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static String stripExtension(String filename) {
|
||||||
|
if (filename == null) return null;
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
return dot > 0 ? filename.substring(0, dot) : filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ParsedFilename(LocalDate date, String firstName, String lastName) {
|
||||||
|
String title() {
|
||||||
|
String dateDisplay = String.format("%02d.%02d.%d",
|
||||||
|
date.getDayOfMonth(), date.getMonthValue(), date.getYear());
|
||||||
|
return firstName + " " + lastName + " (" + dateDisplay + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a structured filename into its date and name components.
|
||||||
|
*
|
||||||
|
* Algorithm: split stem on "_", identify the date token (first or last segment),
|
||||||
|
* treat the outermost remaining segment as firstName, rest as lastName parts.
|
||||||
|
* Compound last names (e.g. "de_Gruyter") are supported naturally.
|
||||||
|
* Returns null for unrecognised filenames.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* 18881025_de_Gruyter_Walter.pdf → date=1888-10-25, firstName=Walter, lastName=de Gruyter
|
||||||
|
* 1965-03-12_Mueller_Hans.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller
|
||||||
|
* Mueller_Hans_19650312.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller
|
||||||
|
*/
|
||||||
|
private static ParsedFilename parseFilenameData(String filename) {
|
||||||
|
if (filename == null) return null;
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
if (dot < 0) return null;
|
||||||
|
String stem = filename.substring(0, dot);
|
||||||
|
|
||||||
|
String[] parts = stem.split("_", -1);
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
|
||||||
|
String dateIso;
|
||||||
|
String[] nameParts;
|
||||||
|
|
||||||
|
String dateFromFirst = tryParseDate(parts[0]);
|
||||||
|
if (dateFromFirst != null) {
|
||||||
|
dateIso = dateFromFirst;
|
||||||
|
nameParts = Arrays.copyOfRange(parts, 1, parts.length);
|
||||||
|
} else {
|
||||||
|
String dateFromLast = tryParseDate(parts[parts.length - 1]);
|
||||||
|
if (dateFromLast == null) return null;
|
||||||
|
dateIso = dateFromLast;
|
||||||
|
nameParts = Arrays.copyOfRange(parts, 0, parts.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameParts.length < 2) return null;
|
||||||
|
for (String p : nameParts) {
|
||||||
|
if (!p.matches("\\p{L}+")) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String firstName = nameParts[nameParts.length - 1];
|
||||||
|
String lastName = String.join(" ", Arrays.copyOfRange(nameParts, 0, nameParts.length - 1));
|
||||||
|
return new ParsedFilename(LocalDate.parse(dateIso), firstName, lastName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by tests and as a public utility; delegates to parseFilenameData.
|
||||||
|
static String titleFromFilename(String filename) {
|
||||||
|
if (filename == null) return null;
|
||||||
|
ParsedFilename parsed = parseFilenameData(filename);
|
||||||
|
return parsed != null ? parsed.title() : stripExtension(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String tryParseDate(String s) {
|
||||||
|
if (s.matches("\\d{4}-\\d{2}-\\d{2}")) {
|
||||||
|
int m = Integer.parseInt(s.substring(5, 7));
|
||||||
|
int d = Integer.parseInt(s.substring(8, 10));
|
||||||
|
if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s;
|
||||||
|
} else if (s.matches("\\d{8}")) {
|
||||||
|
int m = Integer.parseInt(s.substring(4, 6));
|
||||||
|
int d = Integer.parseInt(s.substring(6, 8));
|
||||||
|
if (m >= 1 && m <= 12 && d >= 1 && d <= 31)
|
||||||
|
return s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6, 8);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static String sha256Hex(byte[] bytes) {
|
private static String sha256Hex(byte[] bytes) {
|
||||||
try {
|
try {
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
|||||||
@@ -312,6 +312,9 @@ public class MassImportService {
|
|||||||
.originalFilename(originalFilename)
|
.originalFilename(originalFilename)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
|
// Heuristic: mark as complete if at least one key field is present in the spreadsheet row
|
||||||
|
boolean metadataComplete = date != null || !senderRaw.isBlank() || !receiversRaw.isBlank();
|
||||||
|
|
||||||
doc.setTitle(buildTitle(index, date, location));
|
doc.setTitle(buildTitle(index, date, location));
|
||||||
doc.setFilePath(s3Key);
|
doc.setFilePath(s3Key);
|
||||||
doc.setContentType(contentType);
|
doc.setContentType(contentType);
|
||||||
@@ -325,6 +328,7 @@ public class MassImportService {
|
|||||||
doc.setSender(sender);
|
doc.setSender(sender);
|
||||||
doc.getReceivers().addAll(receivers);
|
doc.getReceivers().addAll(receivers);
|
||||||
if (tag != null) doc.getTags().add(tag);
|
if (tag != null) doc.getTags().add(tag);
|
||||||
|
doc.setMetadataComplete(metadataComplete);
|
||||||
|
|
||||||
documentRepository.save(doc);
|
documentRepository.save(doc);
|
||||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
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.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class NotificationService {
|
||||||
|
|
||||||
|
private final NotificationRepository notificationRepository;
|
||||||
|
private final UserService userService;
|
||||||
|
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) {
|
||||||
|
if (type != null && Boolean.FALSE.equals(read)) {
|
||||||
|
return notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable)
|
||||||
|
.map(this::toDTO);
|
||||||
|
}
|
||||||
|
if (type != null) {
|
||||||
|
return notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable)
|
||||||
|
.map(this::toDTO);
|
||||||
|
}
|
||||||
|
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable)
|
||||||
|
.map(this::toDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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));
|
||||||
|
}
|
||||||
|
|
||||||
|
private NotificationDTO toDTO(Notification n) {
|
||||||
|
return new NotificationDTO(
|
||||||
|
n.getId(),
|
||||||
|
n.getType(),
|
||||||
|
n.getDocumentId(),
|
||||||
|
n.getReferenceId(),
|
||||||
|
n.getAnnotationId(),
|
||||||
|
n.isRead(),
|
||||||
|
n.getCreatedAt(),
|
||||||
|
n.getActorName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
@@ -42,6 +43,10 @@ public class PersonService {
|
|||||||
return personRepository.findAllById(ids);
|
return personRepository.findAllById(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<Person> findByName(String firstName, String lastName) {
|
||||||
|
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person findOrCreateByAlias(String rawName) {
|
public Person findOrCreateByAlias(String rawName) {
|
||||||
String alias = rawName.trim();
|
String alias = rawName.trim();
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -78,6 +79,18 @@ public class UserService {
|
|||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
|
.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
|
@Transactional
|
||||||
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
|
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
|
||||||
AppUser user = getById(userId);
|
AppUser user = getById(userId);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ spring:
|
|||||||
enabled: false # Managed explicitly via FlywayConfig bean
|
enabled: false # Managed explicitly via FlywayConfig bean
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
|
open-in-view: false # Prevents holding DB connections for the full HTTP request lifecycle
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: none
|
ddl-auto: none
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Add ON DELETE CASCADE to document_tags and document_receivers so that
|
||||||
|
-- deleting a document automatically removes its tag and receiver associations.
|
||||||
|
|
||||||
|
ALTER TABLE public.document_tags
|
||||||
|
DROP CONSTRAINT fkc99c5qjulwx9gru07yrhicgd2,
|
||||||
|
ADD CONSTRAINT fkc99c5qjulwx9gru07yrhicgd2
|
||||||
|
FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE public.document_receivers
|
||||||
|
DROP CONSTRAINT fks7t60twjgfmpeqcuc3g0fvjpm,
|
||||||
|
ADD CONSTRAINT fks7t60twjgfmpeqcuc3g0fvjpm
|
||||||
|
FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add metadata_complete flag to documents.
|
||||||
|
-- Existing rows default to true (already reviewed before this feature existed).
|
||||||
|
-- New documents created via Java will receive false from the entity default.
|
||||||
|
|
||||||
|
ALTER TABLE documents
|
||||||
|
ADD COLUMN metadata_complete BOOLEAN NOT NULL DEFAULT TRUE;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- 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);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,29 @@ class AnnotationControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception {
|
void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception {
|
||||||
@@ -132,4 +155,51 @@ class AnnotationControllerTest {
|
|||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isNoContent());
|
.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class CommentControllerTest {
|
|||||||
void postDocumentComment_returns201_whenHasPermission() throws Exception {
|
void postDocumentComment_returns201_whenHasPermission() throws Exception {
|
||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
@@ -89,6 +89,18 @@ class CommentControllerTest {
|
|||||||
.andExpect(jsonPath("$.content").value("Test comment"));
|
.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 ────────
|
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -104,7 +116,20 @@ class CommentControllerTest {
|
|||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
||||||
.authorName("Anna").content("Test comment").build();
|
.authorName("Anna").content("Test comment").build();
|
||||||
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void replyToComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
||||||
|
.authorName("Anna").content("Test comment").build();
|
||||||
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
@@ -163,6 +188,18 @@ class CommentControllerTest {
|
|||||||
.andExpect(status().isOk());
|
.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 ────────
|
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -179,7 +216,20 @@ class CommentControllerTest {
|
|||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||||
.authorName("Hans").content("Test comment").build();
|
.authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void postAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||||
|
.authorName("Hans").content("Test comment").build();
|
||||||
|
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
@@ -194,10 +244,39 @@ class CommentControllerTest {
|
|||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||||
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
||||||
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
@@ -21,9 +23,13 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
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.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
@@ -52,13 +58,32 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_returns200_whenAuthenticated() throws Exception {
|
void search_returns200_whenAuthenticated() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(Collections.emptyList());
|
.thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
.andExpect(status().isOk());
|
.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 ─────────────────────────────────────────────────
|
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -121,6 +146,287 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── DELETE /api/documents/{id} ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
|
.delete("/api/documents/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
|
.delete("/api/documents/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
|
.delete("/api/documents/" + id))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/quick-upload ────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_returns200_withValidPdfFile() throws Exception {
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
|
||||||
|
when(documentService.storeDocument(any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(doc, true));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
||||||
|
.andExpect(jsonPath("$.updated").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_placesDocumentInUpdated_whenFilenameAlreadyExists() throws Exception {
|
||||||
|
Document existing = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("Alter Brief").originalFilename("scan001.pdf").build();
|
||||||
|
when(documentService.storeDocument(any()))
|
||||||
|
.thenReturn(new DocumentService.StoreResult(existing, false));
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_skipsUnsupportedFileType_andReturnsError() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
|
||||||
|
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/{id}/file ────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getDocumentFile_returns404_whenDocHasNoFilePath() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("Brief").build(); // filePath == null
|
||||||
|
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/" + id + "/file"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getDocumentFile_returns200_withContentTypeFromDoc() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("Brief")
|
||||||
|
.filePath("docs/brief.pdf").contentType("application/pdf")
|
||||||
|
.originalFilename("brief.pdf").build();
|
||||||
|
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||||
|
java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{1, 2, 3});
|
||||||
|
when(fileService.downloadFile("docs/brief.pdf"))
|
||||||
|
.thenReturn(new FileService.S3FileDownload(
|
||||||
|
new org.springframework.core.io.InputStreamResource(stream), "application/octet-stream"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/" + id + "/file"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getDocumentFile_returns200_withContentTypeFromStorage_whenDocContentTypeIsBlank() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("Brief")
|
||||||
|
.filePath("docs/brief.pdf").contentType(" ") // blank → falls back to storage type
|
||||||
|
.originalFilename("brief.pdf").build();
|
||||||
|
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||||
|
java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{1, 2, 3});
|
||||||
|
when(fileService.downloadFile("docs/brief.pdf"))
|
||||||
|
.thenReturn(new FileService.S3FileDownload(
|
||||||
|
new org.springframework.core.io.InputStreamResource(stream), "application/pdf"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/" + id + "/file"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getDocumentFile_returns404_whenStorageFileNotFound() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("Brief")
|
||||||
|
.filePath("docs/missing.pdf").contentType("application/pdf")
|
||||||
|
.originalFilename("missing.pdf").build();
|
||||||
|
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||||
|
when(fileService.downloadFile("docs/missing.pdf"))
|
||||||
|
.thenThrow(new FileService.StorageFileNotFoundException("not found"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/" + id + "/file"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/quick-upload — null/empty files ─────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.updated").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/incomplete-count ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getIncompleteCount_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete-count"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getIncompleteCount_returns200_withCount() throws Exception {
|
||||||
|
when(documentService.getIncompleteCount()).thenReturn(3L);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete-count"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.count").value(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/incomplete ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getIncomplete_returns200_withDTOList() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
IncompleteDocumentDTO dto = new IncompleteDocumentDTO(id, "Unvollständig");
|
||||||
|
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||||
|
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getIncomplete_withSizeParam_passesItToService() throws Exception {
|
||||||
|
when(documentService.findIncompleteDocuments(5)).thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete").param("size", "5"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(documentService).findIncompleteDocuments(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getIncomplete_usesDefaultSizeWhenNotSpecified() throws Exception {
|
||||||
|
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(documentService).findIncompleteDocuments(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNextIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||||
|
.param("excludeId", UUID.randomUUID().toString()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getNextIncomplete_returns200_whenNextExists() throws Exception {
|
||||||
|
UUID excludeId = UUID.randomUUID();
|
||||||
|
Document next = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("Nächster").originalFilename("next.pdf").build();
|
||||||
|
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.of(next));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||||
|
.param("excludeId", excludeId.toString()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.title").value("Nächster"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
|
||||||
|
UUID excludeId = UUID.randomUUID();
|
||||||
|
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||||
|
.param("excludeId", excludeId.toString()))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/recent-activity ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getRecentActivity_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/recent-activity"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getRecentActivity_returnsOkWithDocuments() throws Exception {
|
||||||
|
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
||||||
|
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
||||||
|
when(documentService.getRecentActivity(5)).thenReturn(List.of(doc1, doc2));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/recent-activity").param("size", "5"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].title").value("Alpha"))
|
||||||
|
.andExpect(jsonPath("$[1].title").value("Beta"));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
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");
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
@@ -11,15 +12,20 @@ import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
|||||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.test.context.support.WithMockUser;
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@WebMvcTest(PersonController.class)
|
@WebMvcTest(PersonController.class)
|
||||||
@@ -32,6 +38,101 @@ class PersonControllerTest {
|
|||||||
@MockitoBean DocumentService documentService;
|
@MockitoBean DocumentService documentService;
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
@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 {
|
||||||
|
Person person = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
|
when(personService.findAll("Hans")).thenReturn(List.of(person));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons").param("q", "Hans"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].firstName").value("Hans"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 ─────────────────────────────
|
// ─── GET /api/persons/{id}/received-documents ─────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -49,4 +150,158 @@ class PersonControllerTest {
|
|||||||
mockMvc.perform(get("/api/persons/{id}/received-documents", personId))
|
mockMvc.perform(get("/api/persons/{id}/received-documents", personId))
|
||||||
.andExpect(status().isOk());
|
.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
|
||||||
|
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"lastName\":\"Müller\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
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
|
||||||
|
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void createPerson_returns200_whenValid() throws Exception {
|
||||||
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
|
when(personService.createPerson(eq("Hans"), eq("Müller"), any())).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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
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.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,4 +183,100 @@ class AnnotationServiceTest {
|
|||||||
|
|
||||||
verify(annotationRepository, never()).save(any());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import java.util.UUID;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
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.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -30,6 +33,8 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
|
|||||||
class CommentServiceTest {
|
class CommentServiceTest {
|
||||||
|
|
||||||
@Mock CommentRepository commentRepository;
|
@Mock CommentRepository commentRepository;
|
||||||
|
@Mock UserService userService;
|
||||||
|
@Mock NotificationService notificationService;
|
||||||
@InjectMocks CommentService commentService;
|
@InjectMocks CommentService commentService;
|
||||||
|
|
||||||
// ─── postComment ──────────────────────────────────────────────────────────
|
// ─── postComment ──────────────────────────────────────────────────────────
|
||||||
@@ -43,7 +48,7 @@ class CommentServiceTest {
|
|||||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
||||||
}
|
}
|
||||||
@@ -56,11 +61,28 @@ class CommentServiceTest {
|
|||||||
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
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 ───────────────────────────────────────────────────────
|
// ─── replyToComment ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -70,7 +92,7 @@ class CommentServiceTest {
|
|||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
|
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author))
|
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", List.of(), author))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||||
|
|
||||||
@@ -91,11 +113,12 @@ class CommentServiceTest {
|
|||||||
|
|
||||||
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
|
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
|
||||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(existingReply));
|
||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author);
|
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getParentId()).isEqualTo(rootId);
|
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||||
}
|
}
|
||||||
@@ -110,15 +133,59 @@ class CommentServiceTest {
|
|||||||
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||||
|
|
||||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author);
|
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getParentId()).isEqualTo(rootId);
|
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 ──────────────────────────────────────────────────────────
|
// ─── editComment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -233,6 +300,181 @@ class CommentServiceTest {
|
|||||||
assertThat(result.get(0).getReplies()).containsExactly(reply);
|
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 ──────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private AppUser buildAdmin() {
|
private AppUser buildAdmin() {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -374,6 +374,366 @@ class DocumentVersionServiceTest {
|
|||||||
assertThat(count).isEqualTo(2);
|
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 ──────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void authenticateAs(String username) {
|
private void authenticateAs(String username) {
|
||||||
|
|||||||
@@ -4,15 +4,23 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import software.amazon.awssdk.core.ResponseInputStream;
|
||||||
import software.amazon.awssdk.core.sync.RequestBody;
|
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.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.PutObjectRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.any;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@@ -82,4 +90,111 @@ class FileServiceTest {
|
|||||||
|
|
||||||
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
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.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.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 JavaMailSender mailSender;
|
||||||
|
@Mock SseEmitterRegistry sseEmitterRegistry;
|
||||||
|
|
||||||
|
NotificationService notificationService;
|
||||||
|
|
||||||
|
private AppUser userA;
|
||||||
|
private AppUser userB;
|
||||||
|
private AppUser userC;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
notificationService = new NotificationService(notificationRepository, userService, 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, 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, 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.argThat;
|
|||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -23,8 +24,11 @@ import org.raddatz.familienarchiv.model.AppUser;
|
|||||||
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
||||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
|
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.mail.javamail.JavaMailSender;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class PasswordResetServiceTest {
|
class PasswordResetServiceTest {
|
||||||
@@ -123,4 +127,62 @@ class PasswordResetServiceTest {
|
|||||||
assertThatThrownBy(() -> service.resetPassword(req))
|
assertThatThrownBy(() -> service.resetPassword(req))
|
||||||
.isInstanceOf(DomainException.class);
|
.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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,4 +117,50 @@ class PersonNameParserTest {
|
|||||||
assertThat(result.firstName()).isEqualTo("?");
|
assertThat(result.firstName()).isEqualTo("?");
|
||||||
assertThat(result.lastName()).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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,98 @@ class PersonServiceTest {
|
|||||||
assertThat(personService.getById(id)).isEqualTo(person);
|
assertThat(personService.getById(id)).isEqualTo(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── findAll ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_returnsAll_whenQueryIsNull() {
|
||||||
|
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build());
|
||||||
|
when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected);
|
||||||
|
|
||||||
|
assertThat(personService.findAll(null)).isEqualTo(expected);
|
||||||
|
verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc();
|
||||||
|
verify(personRepository, never()).searchByName(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_returnsAll_whenQueryIsBlank() {
|
||||||
|
List<Person> expected = List.of();
|
||||||
|
when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected);
|
||||||
|
|
||||||
|
assertThat(personService.findAll(" ")).isEqualTo(expected);
|
||||||
|
verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc();
|
||||||
|
verify(personRepository, never()).searchByName(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_searchesByName_whenQueryIsNonBlank() {
|
||||||
|
List<Person> expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Müller").build());
|
||||||
|
when(personRepository.searchByName("Anna")).thenReturn(expected);
|
||||||
|
|
||||||
|
assertThat(personService.findAll("Anna")).isEqualTo(expected);
|
||||||
|
verify(personRepository).searchByName("Anna");
|
||||||
|
verify(personRepository, never()).findAllByOrderByLastNameAscFirstNameAsc();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 ─────────────────────────────────────────────────
|
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -144,6 +236,22 @@ class PersonServiceTest {
|
|||||||
.isEqualTo(400);
|
.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
|
@Test
|
||||||
void updatePerson_allowsSameYear() {
|
void updatePerson_allowsSameYear() {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,20 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.UserGroup;
|
||||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
import org.raddatz.familienarchiv.repository.UserGroupRepository;
|
import org.raddatz.familienarchiv.repository.UserGroupRepository;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -216,6 +219,78 @@ class UserServiceTest {
|
|||||||
verify(userRepository).save(argThat(u -> "newHash".equals(u.getPassword())));
|
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 ─────────────────────────────────────────────────────────
|
// ─── getGroupById ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -226,4 +301,378 @@ class UserServiceTest {
|
|||||||
assertThatThrownBy(() -> userService.getGroupById(id))
|
assertThatThrownBy(() -> userService.getGroupById(id))
|
||||||
.isInstanceOf(DomainException.class);
|
.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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
backend/src/test/resources/application-test.yaml
Normal file
15
backend/src/test/resources/application-test.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -29,3 +29,4 @@ src/lib/paraglide
|
|||||||
# (committed as a stub; overwritten by the real spec after generation)
|
# (committed as a stub; overwritten by the real spec after generation)
|
||||||
# src/lib/generated/api.ts
|
# src/lib/generated/api.ts
|
||||||
src/lib/paraglide_bak*
|
src/lib/paraglide_bak*
|
||||||
|
/coverage
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ bun.lockb
|
|||||||
# Test artifacts
|
# Test artifacts
|
||||||
/test-results/
|
/test-results/
|
||||||
/e2e/.auth/
|
/e2e/.auth/
|
||||||
|
/coverage/
|
||||||
|
|||||||
58
frontend/e2e/accessibility.spec.ts
Normal file
58
frontend/e2e/accessibility.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automated accessibility checks using axe-core (wcag2a + wcag2aa).
|
||||||
|
* Authenticated pages use the stored admin session from playwright.config.ts.
|
||||||
|
* The login page test overrides to an unauthenticated context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AUTHENTICATED_PAGES = [
|
||||||
|
{ name: 'home', path: '/' },
|
||||||
|
{ name: 'persons', path: '/persons' },
|
||||||
|
{ name: 'admin', path: '/admin' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||||
|
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Accessibility — authenticated pages', () => {
|
||||||
|
for (const { name, path } of AUTHENTICATED_PAGES) {
|
||||||
|
test(`${name} page has no critical wcag2a/wcag2aa violations`, async ({ page }) => {
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
const results = await buildAxe(page).analyze();
|
||||||
|
|
||||||
|
if (results.violations.length > 0) {
|
||||||
|
const summary = results.violations
|
||||||
|
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||||
|
.join('\n');
|
||||||
|
console.log(`\nAccessibility violations on ${name}:\n${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Accessibility — login page', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('login page has no critical wcag2a/wcag2aa violations', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByLabel('Benutzername')).toBeVisible();
|
||||||
|
|
||||||
|
const results = await buildAxe(page).analyze();
|
||||||
|
|
||||||
|
if (results.violations.length > 0) {
|
||||||
|
const summary = results.violations
|
||||||
|
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||||
|
.join('\n');
|
||||||
|
console.log(`\nAccessibility violations on login:\n${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
180
frontend/e2e/bottom-panel.spec.ts
Normal file
180
frontend/e2e/bottom-panel.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom panel E2E tests — issue #62.
|
||||||
|
* Verifies the new document detail layout: full-viewport viewer + floating bottom panel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let pdfDocHref: string;
|
||||||
|
let noFileDocHref: string;
|
||||||
|
|
||||||
|
test.describe('Document bottom panel', () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
// Create a document with a PDF and a date for metadata tests.
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Bottom Panel Test', documentDate: '1945-05-08' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
|
||||||
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
documentDate: '1945-05-08',
|
||||||
|
transcription: 'Dies ist eine vollständige Transkription des Dokuments für den E2E-Test.',
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||||
|
pdfDocHref = `${baseURL}/documents/${doc.id}`;
|
||||||
|
|
||||||
|
// Create a document WITHOUT a file — panel should open to Metadaten by default.
|
||||||
|
const noFileRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Bottom Panel No-File Test' }
|
||||||
|
});
|
||||||
|
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
|
||||||
|
noFileDocHref = `${baseURL}/documents/${noFileRes.json().then ? (await noFileRes.json()).id : ''}`;
|
||||||
|
const noFileDoc = await noFileRes.json();
|
||||||
|
noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bottom panel tab bar is visible and panel content is closed by default on a PDF document', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
// Clear localStorage to ensure no previous panel state.
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Tab bar must always be visible.
|
||||||
|
await expect(page.getByRole('button', { name: 'Metadaten' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Transkription' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Diskussion' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Verlauf' })).toBeVisible();
|
||||||
|
|
||||||
|
// Panel content must NOT be visible when closed.
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-closed-default.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking Metadaten tab opens the panel and shows metadata content', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Metadaten' }).click();
|
||||||
|
|
||||||
|
// Panel content becomes visible.
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
|
||||||
|
// Metadata section heading should be present.
|
||||||
|
await expect(page.getByText('Details', { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-metadata.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking Transkription tab shows transcription text', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Transkription' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText('Dies ist eine vollständige Transkription', { exact: false })
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-transcription.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking Diskussion tab shows the comment input', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Diskussion' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder('Kommentar schreiben…')).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-discussion.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking × close button collapses the panel content', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Open the panel first.
|
||||||
|
await page.getByRole('button', { name: 'Metadaten' }).click();
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
|
||||||
|
// Close it.
|
||||||
|
await page.locator('[data-testid="panel-close-btn"]').click();
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Tab bar still visible after closing.
|
||||||
|
await expect(page.getByRole('button', { name: 'Metadaten' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-closed-after-x.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('panel open state persists after page reload', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Open the panel to Diskussion.
|
||||||
|
await page.getByRole('button', { name: 'Diskussion' }).click();
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
|
||||||
|
// Reload — panel should re-open on the same tab.
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder('Kommentar schreiben…')).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-persisted.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document without a file opens panel to Metadaten by default', async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await page.goto(noFileDocHref);
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Panel should be open to Metadaten by default when there is no file.
|
||||||
|
await expect(page.locator('[data-testid="bottom-panel-content"]')).toBeVisible();
|
||||||
|
await expect(page.getByText('Details', { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/bottom-panel-no-file-default.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
62
frontend/e2e/dashboard-screenshots.spec.ts
Normal file
62
frontend/e2e/dashboard-screenshots.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -25,7 +25,7 @@ test.describe('Document list', () => {
|
|||||||
|
|
||||||
test('navigation bar shows active state for Dokumente', async ({ page }) => {
|
test('navigation bar shows active state for Dokumente', async ({ page }) => {
|
||||||
const navLink = page.getByRole('navigation').getByRole('link', { name: 'Dokumente' });
|
const navLink = page.getByRole('navigation').getByRole('link', { name: 'Dokumente' });
|
||||||
await expect(navLink).toHaveClass(/text-brand-navy/);
|
await expect(navLink).toHaveClass(/bg-nav-active/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('text search filters the document list', async ({ page }) => {
|
test('text search filters the document list', async ({ page }) => {
|
||||||
@@ -77,12 +77,49 @@ test.describe('Document detail', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('New document', () => {
|
test.describe('New document', () => {
|
||||||
test('renders the upload form', async ({ page }) => {
|
test('renders the upload form with file input first', async ({ page }) => {
|
||||||
await page.goto('/documents/new');
|
await page.goto('/documents/new');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible();
|
||||||
await expect(page.getByLabel('Titel')).toBeVisible();
|
// File input comes before the title field in DOM order
|
||||||
|
const fileInput = page.locator('input[type="file"]');
|
||||||
|
const titleInput = page.getByLabel('Titel');
|
||||||
|
await expect(fileInput).toBeVisible();
|
||||||
|
await expect(titleInput).toBeVisible();
|
||||||
|
const fileBox = await fileInput.boundingBox();
|
||||||
|
const titleBox = await titleInput.boundingBox();
|
||||||
|
expect(fileBox!.y).toBeLessThan(titleBox!.y);
|
||||||
await page.screenshot({ path: 'test-results/e2e/document-new.png' });
|
await page.screenshot({ path: 'test-results/e2e/document-new.png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('title field is pre-filled from filename when a file is selected', async ({ page }) => {
|
||||||
|
await page.goto('/documents/new');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||||
|
const fileInput = page.locator('input[type="file"]');
|
||||||
|
await fileInput.setInputFiles({
|
||||||
|
name: 'Brief_1965.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
});
|
||||||
|
await expect(page.getByLabel('Titel')).toHaveValue('Brief_1965');
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/document-new-filename-prefill.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('typed title is not overwritten when a file is selected', async ({ page }) => {
|
||||||
|
await page.goto('/documents/new');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.getByLabel('Titel').fill('Weihnachtsbrief 1965');
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||||
|
const fileInput = page.locator('input[type="file"]');
|
||||||
|
await fileInput.setInputFiles({
|
||||||
|
name: 'Brief_1965.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
});
|
||||||
|
await expect(page.getByLabel('Titel')).toHaveValue('Weihnachtsbrief 1965');
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/document-new-title-not-overwritten.png' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Document creation', () => {
|
test.describe('Document creation', () => {
|
||||||
@@ -91,12 +128,27 @@ test.describe('Document creation', () => {
|
|||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
await page.getByLabel('Titel').fill('E2E Testbrief');
|
await page.getByLabel('Titel').fill('E2E Testbrief');
|
||||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||||
await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible();
|
||||||
await page.screenshot({ path: 'test-results/e2e/document-create.png' });
|
await page.screenshot({ path: 'test-results/e2e/document-create.png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('user saves a document with only a file — title comes from filename', async ({ page }) => {
|
||||||
|
await page.goto('/documents/new');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||||
|
await page.locator('input[type="file"]').setInputFiles({
|
||||||
|
name: 'Brief_1965.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Brief_1965' })).toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/document-create-file-only.png' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Document editing', () => {
|
test.describe('Document editing', () => {
|
||||||
@@ -112,10 +164,10 @@ test.describe('Document editing', () => {
|
|||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
await page.getByLabel('Titel').fill('E2E Testbrief (überarbeitet)');
|
await page.getByLabel('Titel').fill('E2E Testbrief (überarbeitet)');
|
||||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
await expect(page).toHaveURL(/\/documents\/[^/]+$/);
|
||||||
await expect(page.getByText('E2E Testbrief (überarbeitet)')).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'E2E Testbrief (überarbeitet)' })).toBeVisible();
|
||||||
await page.screenshot({ path: 'test-results/e2e/document-edit-save.png' });
|
await page.screenshot({ path: 'test-results/e2e/document-edit-save.png' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -327,10 +379,12 @@ test.describe('PDF annotations — admin', () => {
|
|||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
|
||||||
// Ensure annotation is visible before enabling annotate mode
|
// Ensure at least one annotation is visible before enabling annotate mode
|
||||||
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||||
timeout: 8000
|
timeout: 8000
|
||||||
});
|
});
|
||||||
|
// Record count now — the draw test may have created more than one annotation
|
||||||
|
const countBefore = await page.locator('[data-testid^="annotation-"]').count();
|
||||||
|
|
||||||
// Enable annotate mode to show delete buttons
|
// Enable annotate mode to show delete buttons
|
||||||
await page.getByRole('button', { name: /^annotieren$/i }).click();
|
await page.getByRole('button', { name: /^annotieren$/i }).click();
|
||||||
@@ -339,7 +393,7 @@ test.describe('PDF annotations — admin', () => {
|
|||||||
await expect(deleteBtn).toBeVisible({ timeout: 8000 });
|
await expect(deleteBtn).toBeVisible({ timeout: 8000 });
|
||||||
await deleteBtn.click();
|
await deleteBtn.click();
|
||||||
|
|
||||||
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, {
|
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(countBefore - 1, {
|
||||||
timeout: 8000
|
timeout: 8000
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -407,7 +461,10 @@ test.describe('PDF annotations — file hash versioning', () => {
|
|||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
|
||||||
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { timeout: 8000 });
|
// Use :not() to exclude the outdated-notice element whose testid also starts with "annotation-"
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid^="annotation-"]:not([data-testid="annotation-outdated-notice"])')
|
||||||
|
).toHaveCount(0, { timeout: 8000 });
|
||||||
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
|
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ test.describe('Document history panel', () => {
|
|||||||
await page.goto('/documents/new');
|
await page.goto('/documents/new');
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
await page.getByLabel('Titel').fill('E2E History Test Dokument');
|
await page.getByLabel('Titel').fill('E2E History Test Dokument');
|
||||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||||
// Wait for redirect to the new document's UUID-based URL (not /documents/new)
|
// Wait for redirect to the new document's UUID-based URL (not /documents/new)
|
||||||
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
||||||
docPath = new URL(page.url()).pathname;
|
docPath = new URL(page.url()).pathname;
|
||||||
@@ -34,7 +34,7 @@ test.describe('Document history panel', () => {
|
|||||||
await page.goto(`${docPath}/edit`);
|
await page.goto(`${docPath}/edit`);
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
await page.getByLabel('Titel').fill('E2E History Test Dokument (bearbeitet)');
|
await page.getByLabel('Titel').fill('E2E History Test Dokument (bearbeitet)');
|
||||||
await page.getByRole('button', { name: /Speichern/i }).click();
|
await page.getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||||
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
await context.close();
|
await context.close();
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ test.describe('Conversations', () => {
|
|||||||
test('nav link is active on the conversations page', async ({ page }) => {
|
test('nav link is active on the conversations page', async ({ page }) => {
|
||||||
await page.goto('/conversations');
|
await page.goto('/conversations');
|
||||||
const navLink = page.getByRole('link', { name: 'Konversationen' });
|
const navLink = page.getByRole('link', { name: 'Konversationen' });
|
||||||
await expect(navLink).toHaveClass(/text-brand-navy/);
|
await expect(navLink).toHaveClass(/bg-nav-active/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sort toggle changes the button label', async ({ page }) => {
|
test('sort toggle changes the button label', async ({ page }) => {
|
||||||
|
|||||||
85
frontend/e2e/proofshots.ts
Normal file
85
frontend/e2e/proofshots.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
frontend/e2e/theme.spec.ts
Normal file
73
frontend/e2e/theme.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Theme toggle', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Clear any saved theme preference before each test
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(() => localStorage.removeItem('theme'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle button is visible in the header', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(
|
||||||
|
page.getByRole('banner').getByRole('button', { name: /dark mode|light mode/i })
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the toggle switches to dark mode', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
const html = page.locator('html');
|
||||||
|
await expect(html).not.toHaveAttribute('data-theme', 'dark');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('banner')
|
||||||
|
.getByRole('button', { name: /dark mode/i })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(html).toHaveAttribute('data-theme', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the toggle again switches back to light mode', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('banner')
|
||||||
|
.getByRole('button', { name: /dark mode/i })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('banner')
|
||||||
|
.getByRole('button', { name: /light mode/i })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('theme persists after page reload', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('banner')
|
||||||
|
.getByRole('button', { name: /dark mode/i })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saved theme is applied before first paint (no flash)', async ({ page }) => {
|
||||||
|
// Set dark theme in localStorage before navigating
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(() => localStorage.setItem('theme', 'dark'));
|
||||||
|
|
||||||
|
// Intercept the initial HTML to verify data-theme is set immediately
|
||||||
|
await page.goto('/');
|
||||||
|
const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||||||
|
expect(theme).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
||||||
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
||||||
"error_file_upload_failed": "Die Datei konnte nicht hochgeladen werden.",
|
"error_file_upload_failed": "Die Datei konnte nicht hochgeladen werden.",
|
||||||
|
"error_unsupported_file_type": "Dieses Dateiformat wird nicht unterstützt.",
|
||||||
"error_user_not_found": "Der Benutzer wurde nicht gefunden.",
|
"error_user_not_found": "Der Benutzer wurde nicht gefunden.",
|
||||||
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
|
"error_import_already_running": "Ein Import läuft bereits. Bitte warten Sie, bis dieser abgeschlossen ist.",
|
||||||
"error_unauthorized": "Sie sind nicht angemeldet.",
|
"error_unauthorized": "Sie sind nicht angemeldet.",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"btn_edit": "Bearbeiten",
|
"btn_edit": "Bearbeiten",
|
||||||
"btn_create": "Erstellen",
|
"btn_create": "Erstellen",
|
||||||
"btn_delete": "Löschen",
|
"btn_delete": "Löschen",
|
||||||
|
"doc_delete_confirm": "Dokument wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
"btn_back_to_overview": "Zurück zur Übersicht",
|
"btn_back_to_overview": "Zurück zur Übersicht",
|
||||||
"btn_back": "Zurück",
|
"btn_back": "Zurück",
|
||||||
"btn_back_to_document": "Zurück zum Dokument",
|
"btn_back_to_document": "Zurück zum Dokument",
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
"form_placeholder_location": "z.B. Berlin, Wien…",
|
"form_placeholder_location": "z.B. Berlin, Wien…",
|
||||||
"form_label_sender": "Absender",
|
"form_label_sender": "Absender",
|
||||||
"form_label_receivers": "Empfänger",
|
"form_label_receivers": "Empfänger",
|
||||||
"form_label_title": "Titel *",
|
"form_label_title": "Titel",
|
||||||
"form_label_tags": "Schlagworte",
|
"form_label_tags": "Schlagworte",
|
||||||
"form_label_content": "Inhalt",
|
"form_label_content": "Inhalt",
|
||||||
"form_placeholder_content": "Kurze Beschreibung des Inhalts…",
|
"form_placeholder_content": "Kurze Beschreibung des Inhalts…",
|
||||||
@@ -73,6 +75,7 @@
|
|||||||
"doc_file_replace_label": "Neue Datei hochladen",
|
"doc_file_replace_label": "Neue Datei hochladen",
|
||||||
"doc_file_replace_note": "(ersetzt die aktuelle Datei)",
|
"doc_file_replace_note": "(ersetzt die aktuelle Datei)",
|
||||||
"doc_current_file_label": "Aktuelle Datei:",
|
"doc_current_file_label": "Aktuelle Datei:",
|
||||||
|
"doc_more_details": "Weitere Details",
|
||||||
"doc_new_heading": "Neues Dokument",
|
"doc_new_heading": "Neues Dokument",
|
||||||
"doc_edit_heading": "Bearbeiten",
|
"doc_edit_heading": "Bearbeiten",
|
||||||
"doc_section_details": "Details",
|
"doc_section_details": "Details",
|
||||||
@@ -255,5 +258,67 @@
|
|||||||
"comment_btn_reply": "Antworten",
|
"comment_btn_reply": "Antworten",
|
||||||
"comment_edited_label": "· bearbeitet",
|
"comment_edited_label": "· bearbeitet",
|
||||||
"comment_panel_title": "Kommentare",
|
"comment_panel_title": "Kommentare",
|
||||||
"comment_panel_close": "Schließen"
|
"comment_panel_close": "Schließen",
|
||||||
|
"doc_panel_tab_metadata": "Metadaten",
|
||||||
|
"doc_panel_tab_transcription": "Transkription",
|
||||||
|
"doc_panel_tab_discussion": "Diskussion",
|
||||||
|
"doc_panel_tab_history": "Verlauf",
|
||||||
|
"doc_panel_annotate": "Annotieren",
|
||||||
|
"doc_panel_annotate_stop": "Fertig",
|
||||||
|
"doc_panel_annotation_thread_title": "Annotation",
|
||||||
|
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||||
|
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||||
|
"pdf_annotations_hide": "Annotierungen verbergen",
|
||||||
|
"upload_drop_hint": "Einzeln oder mehrere Dateien auf einmal hochladen",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_filename_hint": "Tipp: 2024-03-15_Mueller_Hans.pdf → Datum und Absender werden vorausgefüllt",
|
||||||
|
"upload_success": "{count} Dokument(e) erstellt",
|
||||||
|
"upload_duplicate": "{filename} existiert bereits —",
|
||||||
|
"upload_duplicate_link": "Zum Dokument",
|
||||||
|
"upload_invalid_type": "{filename}: Dateiformat nicht unterstützt",
|
||||||
|
"upload_error": "Fehler beim Hochladen von {filename}",
|
||||||
|
"enrich_list_back": "Zurück zur Übersicht",
|
||||||
|
"enrich_list_count": "Dokumente",
|
||||||
|
"btn_save_and_mark_reviewed": "Speichern & abschließen",
|
||||||
|
"btn_mark_for_review": "Zur Überprüfung markieren",
|
||||||
|
"enrich_needs_metadata_title": "Dokumente ohne Metadaten",
|
||||||
|
"enrich_needs_metadata_count": "{count} Dokument(e) warten auf Metadaten",
|
||||||
|
"enrich_needs_metadata_cta": "Jetzt vervollständigen",
|
||||||
|
"enrich_list_heading": "Dokumente ohne Metadaten",
|
||||||
|
"enrich_list_empty_heading": "Alle Dokumente vollständig",
|
||||||
|
"enrich_list_empty_body": "Es gibt keine Dokumente, die noch Metadaten benötigen.",
|
||||||
|
"enrich_list_start": "Überprüfung starten",
|
||||||
|
"enrich_progress": "{count} verbleibend",
|
||||||
|
"enrich_skip": "Überspringen",
|
||||||
|
"enrich_done_heading": "Alles erledigt!",
|
||||||
|
"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_resume_label": "Zuletzt geöffnet:",
|
||||||
|
"dashboard_resume_fallback": "Unbekanntes Dokument"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error_document_no_file": "No file is associated with this document.",
|
"error_document_no_file": "No file is associated with this document.",
|
||||||
"error_file_not_found": "The file could not be found in storage.",
|
"error_file_not_found": "The file could not be found in storage.",
|
||||||
"error_file_upload_failed": "The file could not be uploaded.",
|
"error_file_upload_failed": "The file could not be uploaded.",
|
||||||
|
"error_unsupported_file_type": "This file format is not supported.",
|
||||||
"error_user_not_found": "User not found.",
|
"error_user_not_found": "User not found.",
|
||||||
"error_import_already_running": "An import is already running. Please wait for it to finish.",
|
"error_import_already_running": "An import is already running. Please wait for it to finish.",
|
||||||
"error_unauthorized": "You are not logged in.",
|
"error_unauthorized": "You are not logged in.",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"btn_edit": "Edit",
|
"btn_edit": "Edit",
|
||||||
"btn_create": "Create",
|
"btn_create": "Create",
|
||||||
"btn_delete": "Delete",
|
"btn_delete": "Delete",
|
||||||
|
"doc_delete_confirm": "Really delete this document? This action cannot be undone.",
|
||||||
"btn_back_to_overview": "Back to overview",
|
"btn_back_to_overview": "Back to overview",
|
||||||
"btn_back": "Back",
|
"btn_back": "Back",
|
||||||
"btn_back_to_document": "Back to document",
|
"btn_back_to_document": "Back to document",
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
"form_placeholder_location": "e.g. Berlin, Vienna…",
|
"form_placeholder_location": "e.g. Berlin, Vienna…",
|
||||||
"form_label_sender": "Sender",
|
"form_label_sender": "Sender",
|
||||||
"form_label_receivers": "Recipients",
|
"form_label_receivers": "Recipients",
|
||||||
"form_label_title": "Title *",
|
"form_label_title": "Title",
|
||||||
"form_label_tags": "Tags",
|
"form_label_tags": "Tags",
|
||||||
"form_label_content": "Content",
|
"form_label_content": "Content",
|
||||||
"form_placeholder_content": "Brief description of the content…",
|
"form_placeholder_content": "Brief description of the content…",
|
||||||
@@ -73,6 +75,7 @@
|
|||||||
"doc_file_replace_label": "Upload new file",
|
"doc_file_replace_label": "Upload new file",
|
||||||
"doc_file_replace_note": "(replaces the current file)",
|
"doc_file_replace_note": "(replaces the current file)",
|
||||||
"doc_current_file_label": "Current file:",
|
"doc_current_file_label": "Current file:",
|
||||||
|
"doc_more_details": "More details",
|
||||||
"doc_new_heading": "New document",
|
"doc_new_heading": "New document",
|
||||||
"doc_edit_heading": "Edit",
|
"doc_edit_heading": "Edit",
|
||||||
"doc_section_details": "Details",
|
"doc_section_details": "Details",
|
||||||
@@ -255,5 +258,67 @@
|
|||||||
"comment_btn_reply": "Reply",
|
"comment_btn_reply": "Reply",
|
||||||
"comment_edited_label": "· edited",
|
"comment_edited_label": "· edited",
|
||||||
"comment_panel_title": "Comments",
|
"comment_panel_title": "Comments",
|
||||||
"comment_panel_close": "Close"
|
"comment_panel_close": "Close",
|
||||||
|
"doc_panel_tab_metadata": "Metadata",
|
||||||
|
"doc_panel_tab_transcription": "Transcription",
|
||||||
|
"doc_panel_tab_discussion": "Discussion",
|
||||||
|
"doc_panel_tab_history": "History",
|
||||||
|
"doc_panel_annotate": "Annotate",
|
||||||
|
"doc_panel_annotate_stop": "Done",
|
||||||
|
"doc_panel_annotation_thread_title": "Annotation",
|
||||||
|
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||||
|
"pdf_annotations_show": "Show annotations",
|
||||||
|
"pdf_annotations_hide": "Hide annotations",
|
||||||
|
"upload_drop_hint": "Drop one or multiple files at once",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_filename_hint": "Tip: 2024-03-15_Mueller_Hans.pdf → date and sender pre-filled",
|
||||||
|
"upload_success": "{count} document(s) created",
|
||||||
|
"upload_duplicate": "{filename} already exists —",
|
||||||
|
"upload_duplicate_link": "View document",
|
||||||
|
"upload_invalid_type": "{filename}: unsupported file format",
|
||||||
|
"upload_error": "Error uploading {filename}",
|
||||||
|
"enrich_list_back": "Back to overview",
|
||||||
|
"enrich_list_count": "documents",
|
||||||
|
"btn_save_and_mark_reviewed": "Save & mark as reviewed",
|
||||||
|
"btn_mark_for_review": "Mark for review",
|
||||||
|
"enrich_needs_metadata_title": "Documents without metadata",
|
||||||
|
"enrich_needs_metadata_count": "{count} document(s) waiting for metadata",
|
||||||
|
"enrich_needs_metadata_cta": "Complete now",
|
||||||
|
"enrich_list_heading": "Documents without metadata",
|
||||||
|
"enrich_list_empty_heading": "All documents complete",
|
||||||
|
"enrich_list_empty_body": "There are no documents that still need metadata.",
|
||||||
|
"enrich_list_start": "Start reviewing",
|
||||||
|
"enrich_progress": "{count} remaining",
|
||||||
|
"enrich_skip": "Skip",
|
||||||
|
"enrich_done_heading": "All done!",
|
||||||
|
"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_resume_label": "Last opened:",
|
||||||
|
"dashboard_resume_fallback": "Unknown document"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
||||||
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
||||||
"error_file_upload_failed": "No se pudo subir el archivo.",
|
"error_file_upload_failed": "No se pudo subir el archivo.",
|
||||||
|
"error_unsupported_file_type": "Este formato de archivo no está admitido.",
|
||||||
"error_user_not_found": "Usuario no encontrado.",
|
"error_user_not_found": "Usuario no encontrado.",
|
||||||
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
|
"error_import_already_running": "Ya hay una importación en curso. Por favor, espere a que finalice.",
|
||||||
"error_unauthorized": "No ha iniciado sesión.",
|
"error_unauthorized": "No ha iniciado sesión.",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"btn_edit": "Editar",
|
"btn_edit": "Editar",
|
||||||
"btn_create": "Crear",
|
"btn_create": "Crear",
|
||||||
"btn_delete": "Eliminar",
|
"btn_delete": "Eliminar",
|
||||||
|
"doc_delete_confirm": "¿Realmente eliminar este documento? Esta acción no se puede deshacer.",
|
||||||
"btn_back_to_overview": "Volver al resumen",
|
"btn_back_to_overview": "Volver al resumen",
|
||||||
"btn_back": "Volver",
|
"btn_back": "Volver",
|
||||||
"btn_back_to_document": "Volver al documento",
|
"btn_back_to_document": "Volver al documento",
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
"form_placeholder_location": "p.ej. Berlín, Viena…",
|
"form_placeholder_location": "p.ej. Berlín, Viena…",
|
||||||
"form_label_sender": "Remitente",
|
"form_label_sender": "Remitente",
|
||||||
"form_label_receivers": "Destinatarios",
|
"form_label_receivers": "Destinatarios",
|
||||||
"form_label_title": "Título *",
|
"form_label_title": "Título",
|
||||||
"form_label_tags": "Etiquetas",
|
"form_label_tags": "Etiquetas",
|
||||||
"form_label_content": "Contenido",
|
"form_label_content": "Contenido",
|
||||||
"form_placeholder_content": "Breve descripción del contenido…",
|
"form_placeholder_content": "Breve descripción del contenido…",
|
||||||
@@ -73,6 +75,7 @@
|
|||||||
"doc_file_replace_label": "Subir nuevo archivo",
|
"doc_file_replace_label": "Subir nuevo archivo",
|
||||||
"doc_file_replace_note": "(reemplaza el archivo actual)",
|
"doc_file_replace_note": "(reemplaza el archivo actual)",
|
||||||
"doc_current_file_label": "Archivo actual:",
|
"doc_current_file_label": "Archivo actual:",
|
||||||
|
"doc_more_details": "Más detalles",
|
||||||
"doc_new_heading": "Nuevo documento",
|
"doc_new_heading": "Nuevo documento",
|
||||||
"doc_edit_heading": "Editar",
|
"doc_edit_heading": "Editar",
|
||||||
"doc_section_details": "Detalles",
|
"doc_section_details": "Detalles",
|
||||||
@@ -255,5 +258,67 @@
|
|||||||
"comment_btn_reply": "Responder",
|
"comment_btn_reply": "Responder",
|
||||||
"comment_edited_label": "· editado",
|
"comment_edited_label": "· editado",
|
||||||
"comment_panel_title": "Comentarios",
|
"comment_panel_title": "Comentarios",
|
||||||
"comment_panel_close": "Cerrar"
|
"comment_panel_close": "Cerrar",
|
||||||
|
"doc_panel_tab_metadata": "Metadatos",
|
||||||
|
"doc_panel_tab_transcription": "Transcripción",
|
||||||
|
"doc_panel_tab_discussion": "Discusión",
|
||||||
|
"doc_panel_tab_history": "Historial",
|
||||||
|
"doc_panel_annotate": "Anotar",
|
||||||
|
"doc_panel_annotate_stop": "Listo",
|
||||||
|
"doc_panel_annotation_thread_title": "Anotación",
|
||||||
|
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||||
|
"pdf_annotations_show": "Mostrar anotaciones",
|
||||||
|
"pdf_annotations_hide": "Ocultar anotaciones",
|
||||||
|
"upload_drop_hint": "Uno o varios archivos a la vez",
|
||||||
|
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||||
|
"upload_filename_hint": "Consejo: 2024-03-15_Mueller_Hans.pdf → fecha y remitente prellenados",
|
||||||
|
"upload_success": "{count} documento(s) creado(s)",
|
||||||
|
"upload_duplicate": "{filename} ya existe —",
|
||||||
|
"upload_duplicate_link": "Ver documento",
|
||||||
|
"upload_invalid_type": "{filename}: formato de archivo no admitido",
|
||||||
|
"upload_error": "Error al subir {filename}",
|
||||||
|
"enrich_list_back": "Volver a la vista general",
|
||||||
|
"enrich_list_count": "documentos",
|
||||||
|
"btn_save_and_mark_reviewed": "Guardar y marcar como revisado",
|
||||||
|
"btn_mark_for_review": "Marcar para revisión",
|
||||||
|
"enrich_needs_metadata_title": "Documentos sin metadatos",
|
||||||
|
"enrich_needs_metadata_count": "{count} documento(s) esperando metadatos",
|
||||||
|
"enrich_needs_metadata_cta": "Completar ahora",
|
||||||
|
"enrich_list_heading": "Documentos sin metadatos",
|
||||||
|
"enrich_list_empty_heading": "Todos los documentos completos",
|
||||||
|
"enrich_list_empty_body": "No hay documentos que necesiten metadatos.",
|
||||||
|
"enrich_list_start": "Comenzar revisión",
|
||||||
|
"enrich_progress": "{count} restante(s)",
|
||||||
|
"enrich_skip": "Omitir",
|
||||||
|
"enrich_done_heading": "¡Todo listo!",
|
||||||
|
"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_resume_label": "Último abierto:",
|
||||||
|
"dashboard_resume_fallback": "Documento desconocido"
|
||||||
}
|
}
|
||||||
|
|||||||
209
frontend/package-lock.json
generated
209
frontend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.11.1",
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@inlang/paraglide-js": "^2.5.0",
|
"@inlang/paraglide-js": "^2.5.0",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@types/diff": "^7.0.2",
|
"@types/diff": "^7.0.2",
|
||||||
"@types/node": "^24",
|
"@types/node": "^24",
|
||||||
"@vitest/browser-playwright": "^4.0.10",
|
"@vitest/browser-playwright": "^4.0.10",
|
||||||
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.13.0",
|
"eslint-plugin-svelte": "^3.13.0",
|
||||||
@@ -46,6 +48,19 @@
|
|||||||
"vitest-browser-svelte": "^2.0.1"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -61,6 +76,16 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||||
@@ -71,6 +96,46 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@blazediff/core": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
|
||||||
@@ -2522,6 +2587,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz",
|
||||||
@@ -2755,6 +2851,45 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -3549,6 +3684,13 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
@@ -3686,6 +3828,45 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
@@ -4129,6 +4310,34 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/mini-svg-data-uri": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test": "npm run test:unit -- --run",
|
"test": "npm run test:unit -- --run",
|
||||||
|
"test:coverage": "vitest run --coverage --project=server",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:headed": "playwright test --headed",
|
"test:e2e:headed": "playwright test --headed",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.11.1",
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@inlang/paraglide-js": "^2.5.0",
|
"@inlang/paraglide-js": "^2.5.0",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"@types/diff": "^7.0.2",
|
"@types/diff": "^7.0.2",
|
||||||
"@types/node": "^24",
|
"@types/node": "^24",
|
||||||
"@vitest/browser-playwright": "^4.0.10",
|
"@vitest/browser-playwright": "^4.0.10",
|
||||||
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^3.13.0",
|
"eslint-plugin-svelte": "^3.13.0",
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem('theme');
|
||||||
|
if (t === 'dark' || t === 'light') document.documentElement.setAttribute('data-theme', t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
|
|
||||||
describe('sum test', () => {
|
|
||||||
it('adds 1 + 2 to equal 3', () => {
|
|
||||||
expect(1 + 2).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -25,16 +25,16 @@ let {
|
|||||||
|
|
||||||
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
|
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-brand-sand bg-white shadow-2xl sm:flex"
|
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-line bg-surface shadow-2xl sm:flex"
|
||||||
>
|
>
|
||||||
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||||
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||||
{m.comment_panel_title()}
|
{m.comment_panel_title()}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-label={m.comment_panel_close()}
|
aria-label={m.comment_panel_close()}
|
||||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -60,15 +60,15 @@ let {
|
|||||||
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
|
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
|
||||||
|
|
||||||
<!-- Slide-up panel -->
|
<!-- Slide-up panel -->
|
||||||
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-white shadow-2xl">
|
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-surface shadow-2xl">
|
||||||
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||||
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||||
{m.comment_panel_title()}
|
{m.comment_panel_title()}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-label={m.comment_panel_close()}
|
aria-label={m.comment_panel_close()}
|
||||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
type Annotation = {
|
import type { Annotation } from '$lib/types';
|
||||||
id: string;
|
|
||||||
documentId: string;
|
|
||||||
pageNumber: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
color: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DrawRect = {
|
type DrawRect = {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -103,7 +93,7 @@ function handlePointerUp(event: PointerEvent) {
|
|||||||
let hoveredId = $state<string | null>(null);
|
let hoveredId = $state<string | null>(null);
|
||||||
|
|
||||||
const containerStyle = $derived(
|
const containerStyle = $derived(
|
||||||
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair;' : ''}`
|
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair; touch-action: none;' : ''}`
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -123,8 +113,8 @@ const containerStyle = $derived(
|
|||||||
aria-label="Kommentare anzeigen"
|
aria-label="Kommentare anzeigen"
|
||||||
onclick={() => onAnnotationClick?.(annotation.id)}
|
onclick={() => onAnnotationClick?.(annotation.id)}
|
||||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }}
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }}
|
||||||
onmouseenter={() => (hoveredId = annotation.id)}
|
onpointerenter={() => (hoveredId = annotation.id)}
|
||||||
onmouseleave={() => (hoveredId = null)}
|
onpointerleave={() => (hoveredId = null)}
|
||||||
style="
|
style="
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: {annotation.x * 100}%;
|
left: {annotation.x * 100}%;
|
||||||
|
|||||||
68
frontend/src/lib/components/AnnotationSidePanel.svelte
Normal file
68
frontend/src/lib/components/AnnotationSidePanel.svelte
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import CommentThread from './CommentThread.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documentId: string;
|
||||||
|
activeAnnotationId: string | null;
|
||||||
|
activeAnnotationPage: number | null;
|
||||||
|
canComment: boolean;
|
||||||
|
currentUserId: string | null;
|
||||||
|
canAdmin: boolean;
|
||||||
|
targetCommentId?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
documentId,
|
||||||
|
activeAnnotationId,
|
||||||
|
activeAnnotationPage,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
targetCommentId = null,
|
||||||
|
onClose
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const visible = $derived(activeAnnotationId !== null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 right-0 z-10 flex w-80 flex-col border-l border-line bg-surface shadow-[-4px_0_16px_rgba(0,0,0,0.08)] transition-transform duration-200 {visible
|
||||||
|
? 'translate-x-0'
|
||||||
|
: 'pointer-events-none translate-x-full'}"
|
||||||
|
data-testid="annotation-side-panel"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||||
|
<span class="font-sans text-xs font-medium text-ink">
|
||||||
|
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label={m.comment_panel_close()}
|
||||||
|
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment thread -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
{#if activeAnnotationId}
|
||||||
|
{#key activeAnnotationId}
|
||||||
|
<CommentThread
|
||||||
|
documentId={documentId}
|
||||||
|
annotationId={activeAnnotationId}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
targetCommentId={targetCommentId}
|
||||||
|
loadOnMount={true}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import AnnotationSidePanel from './AnnotationSidePanel.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => []
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
documentId: 'doc-1',
|
||||||
|
activeAnnotationPage: 1,
|
||||||
|
canComment: true,
|
||||||
|
currentUserId: 'user-1',
|
||||||
|
canAdmin: false,
|
||||||
|
onClose: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AnnotationSidePanel – visibility', () => {
|
||||||
|
it('is hidden (translated off-screen) when activeAnnotationId is null', async () => {
|
||||||
|
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: null });
|
||||||
|
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||||
|
expect(panel?.classList.contains('translate-x-full')).toBe(true);
|
||||||
|
expect(panel?.classList.contains('translate-x-0')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is visible when activeAnnotationId is set', async () => {
|
||||||
|
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1' });
|
||||||
|
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||||
|
expect(panel?.classList.contains('translate-x-0')).toBe(true);
|
||||||
|
expect(panel?.classList.contains('translate-x-full')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AnnotationSidePanel – close button', () => {
|
||||||
|
it('calls onClose when the close button is clicked', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1', onClose });
|
||||||
|
await page.getByRole('button', { name: /schließen/i }).click();
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AnnotationSidePanel – targetCommentId forwarding', () => {
|
||||||
|
it('renders CommentThread when annotation is active', async () => {
|
||||||
|
render(AnnotationSidePanel, {
|
||||||
|
...baseProps,
|
||||||
|
activeAnnotationId: 'ann-1',
|
||||||
|
targetCommentId: 'comment-42'
|
||||||
|
});
|
||||||
|
// CommentThread renders inside the panel when activeAnnotationId is set
|
||||||
|
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||||
|
expect(panel).not.toBeNull();
|
||||||
|
expect(panel?.classList.contains('translate-x-0')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render CommentThread when annotation is null', async () => {
|
||||||
|
render(AnnotationSidePanel, {
|
||||||
|
...baseProps,
|
||||||
|
activeAnnotationId: null,
|
||||||
|
targetCommentId: 'comment-42'
|
||||||
|
});
|
||||||
|
// Panel is hidden and no fetch should have been triggered for comments
|
||||||
|
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||||
|
expect(panel?.classList.contains('translate-x-full')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, tick, untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { Comment, CommentReply } from '$lib/types';
|
||||||
type CommentReply = {
|
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
||||||
id: string;
|
import { renderBody, extractContent } from '$lib/utils/mention';
|
||||||
authorId: string | null;
|
import type { MentionDTO } from '$lib/types';
|
||||||
authorName: string;
|
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Comment = {
|
|
||||||
id: string;
|
|
||||||
authorId: string | null;
|
|
||||||
authorName: string;
|
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
replies: CommentReply[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -29,6 +14,7 @@ type Props = {
|
|||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
canAdmin: boolean;
|
canAdmin: boolean;
|
||||||
|
targetCommentId?: string | null;
|
||||||
onCountChange?: (count: number) => void;
|
onCountChange?: (count: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,16 +26,21 @@ let {
|
|||||||
canComment,
|
canComment,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
canAdmin,
|
canAdmin,
|
||||||
|
targetCommentId = null,
|
||||||
onCountChange
|
onCountChange
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||||
|
let highlightedCommentId: string | null = $state(untrack(() => targetCommentId ?? null));
|
||||||
let newText: string = $state('');
|
let newText: string = $state('');
|
||||||
let replyingTo: string | null = $state(null);
|
let replyingTo: string | null = $state(null);
|
||||||
let replyText: string = $state('');
|
let replyText: string = $state('');
|
||||||
let editingId: string | null = $state(null);
|
let editingId: string | null = $state(null);
|
||||||
let editText: string = $state('');
|
let editText: string = $state('');
|
||||||
let posting: boolean = $state(false);
|
let posting: boolean = $state(false);
|
||||||
|
let newMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
let replyMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
let editMentionCandidates: MentionDTO[] = $state([]);
|
||||||
|
|
||||||
const commentsBase = $derived(
|
const commentsBase = $derived(
|
||||||
annotationId
|
annotationId
|
||||||
@@ -94,13 +85,15 @@ async function postComment() {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, newMentionCandidates);
|
||||||
const res = await fetch(commentsBase, {
|
const res = await fetch(commentsBase, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
newText = '';
|
newText = '';
|
||||||
|
newMentionCandidates = [];
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -113,13 +106,15 @@ async function postReply(threadId: string) {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
|
||||||
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
replyText = '';
|
replyText = '';
|
||||||
|
replyMentionCandidates = [];
|
||||||
replyingTo = null;
|
replyingTo = null;
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
@@ -133,13 +128,15 @@ async function saveEdit(commentId: string) {
|
|||||||
if (!text || posting) return;
|
if (!text || posting) return;
|
||||||
posting = true;
|
posting = true;
|
||||||
try {
|
try {
|
||||||
|
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
|
||||||
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: text })
|
body: JSON.stringify({ content, mentionedUserIds })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
editingId = null;
|
editingId = null;
|
||||||
|
editMentionCandidates = [];
|
||||||
await reload();
|
await reload();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -165,6 +162,7 @@ async function deleteComment(commentId: string) {
|
|||||||
function startEdit(comment: Comment | CommentReply) {
|
function startEdit(comment: Comment | CommentReply) {
|
||||||
editingId = comment.id;
|
editingId = comment.id;
|
||||||
editText = comment.content;
|
editText = comment.content;
|
||||||
|
editMentionCandidates = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
@@ -182,183 +180,177 @@ function cancelReply() {
|
|||||||
replyText = '';
|
replyText = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
if (loadOnMount) {
|
if (loadOnMount) {
|
||||||
reload();
|
reload();
|
||||||
|
} else {
|
||||||
|
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
||||||
|
onCountChange?.(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetCommentId) {
|
||||||
|
await tick();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
|
||||||
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove highlight on first user interaction
|
||||||
|
const clearHighlight = () => {
|
||||||
|
highlightedCommentId = null;
|
||||||
|
document.removeEventListener('click', clearHighlight, true);
|
||||||
|
document.removeEventListener('keydown', clearHighlight, true);
|
||||||
|
document.removeEventListener('scroll', clearHighlight, true);
|
||||||
|
};
|
||||||
|
document.addEventListener('click', clearHighlight, true);
|
||||||
|
document.addEventListener('keydown', clearHighlight, true);
|
||||||
|
document.addEventListener('scroll', clearHighlight, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<!--
|
||||||
{#each comments as thread, ti (thread.id)}
|
Renders a single comment or reply entry.
|
||||||
<div class={ti > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
showReplyButton: whether the "Reply" button appears (only on last item in a thread).
|
||||||
<!-- Root comment -->
|
-->
|
||||||
<div>
|
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
|
||||||
{#if editingId === thread.id}
|
{#if editingId === comment.id}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
bind:value={editText}
|
||||||
rows={3}
|
bind:mentionCandidates={editMentionCandidates}
|
||||||
bind:value={editText}
|
rows={3}
|
||||||
></textarea>
|
disabled={posting}
|
||||||
<div class="flex items-center gap-3">
|
onsubmit={() => saveEdit(comment.id)}
|
||||||
<button
|
/>
|
||||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
<div class="flex items-center gap-3">
|
||||||
disabled={posting}
|
<button
|
||||||
onclick={() => saveEdit(thread.id)}
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||||
>
|
disabled={posting}
|
||||||
{m.btn_save()}
|
onclick={() => saveEdit(comment.id)}
|
||||||
</button>
|
>
|
||||||
<button
|
{m.btn_save()}
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
</button>
|
||||||
onclick={cancelEdit}
|
<button
|
||||||
>
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
{m.btn_cancel()}
|
onclick={cancelEdit}
|
||||||
</button>
|
>
|
||||||
</div>
|
{m.btn_cancel()}
|
||||||
</div>
|
</button>
|
||||||
{:else}
|
</div>
|
||||||
<div class="flex items-start justify-between gap-2">
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
{:else}
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<span class="font-sans text-xs font-semibold text-brand-navy"
|
<div class="min-w-0 flex-1">
|
||||||
>{thread.authorName}</span
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
>
|
<span class="font-sans text-xs font-semibold text-ink">{comment.authorName}</span>
|
||||||
<span class="font-sans text-xs text-gray-400">{timeAgo(thread.createdAt)}</span>
|
<span class="font-sans text-xs text-ink-3">{timeAgo(comment.createdAt)}</span>
|
||||||
{#if wasEdited(thread)}
|
{#if wasEdited(comment)}
|
||||||
<span class="font-sans text-xs text-gray-400">
|
<span class="font-sans text-xs text-ink-3">
|
||||||
{m.comment_edited_label()}
|
{m.comment_edited_label()}
|
||||||
{timeAgo(thread.updatedAt)}
|
{timeAgo(comment.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{thread.content}</p>
|
|
||||||
</div>
|
|
||||||
{#if canModify(thread)}
|
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
|
||||||
<button
|
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
||||||
onclick={() => startEdit(thread)}
|
|
||||||
>
|
|
||||||
{m.btn_edit()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
||||||
onclick={() => deleteComment(thread.id)}
|
|
||||||
>
|
|
||||||
{m.btn_delete()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<!-- Reply button on root comment only if there are no replies -->
|
|
||||||
{#if thread.replies.length === 0 && canComment}
|
|
||||||
<div class="mt-1">
|
|
||||||
<button
|
|
||||||
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
|
||||||
onclick={() => startReply(thread.id)}
|
|
||||||
>
|
|
||||||
{m.comment_btn_reply()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
|
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
||||||
|
{@html renderBody(comment.content, comment.mentionDTOs ?? [])}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if canModify(comment)}
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
|
onclick={() => startEdit(comment)}
|
||||||
|
>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
|
onclick={() => deleteComment(comment.id)}
|
||||||
|
>
|
||||||
|
{m.btn_delete()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if showReplyButton && canComment}
|
||||||
|
<div class="mt-1">
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs font-medium text-primary transition-colors hover:text-ink-2"
|
||||||
|
onclick={() => startReply(threadId)}
|
||||||
|
>
|
||||||
|
{m.comment_btn_reply()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#if comments.length === 0}
|
||||||
|
<div class="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
|
<svg
|
||||||
|
class="h-10 w-10 text-ink-3"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="font-sans text-sm text-ink-3">{m.comment_empty_hint()}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#each comments as thread, ti (thread.id)}
|
||||||
|
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
|
||||||
|
<!-- Root comment -->
|
||||||
|
<div
|
||||||
|
data-comment-id={thread.id}
|
||||||
|
class={highlightedCommentId === thread.id
|
||||||
|
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
|
||||||
|
: ''}
|
||||||
|
>
|
||||||
|
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Replies -->
|
<!-- Replies -->
|
||||||
{#each thread.replies as reply, ri (reply.id)}
|
{#each thread.replies as reply, ri (reply.id)}
|
||||||
<div class="mt-3 ml-6 border-l-2 border-brand-sand pl-4">
|
<div
|
||||||
{#if editingId === reply.id}
|
data-comment-id={reply.id}
|
||||||
<div class="flex flex-col gap-2">
|
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
|
||||||
<textarea
|
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
|
||||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
: ''}"
|
||||||
rows={3}
|
>
|
||||||
bind:value={editText}
|
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
|
||||||
></textarea>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
|
||||||
disabled={posting}
|
|
||||||
onclick={() => saveEdit(reply.id)}
|
|
||||||
>
|
|
||||||
{m.btn_save()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
||||||
onclick={cancelEdit}
|
|
||||||
>
|
|
||||||
{m.btn_cancel()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<span class="font-sans text-xs font-semibold text-brand-navy"
|
|
||||||
>{reply.authorName}</span
|
|
||||||
>
|
|
||||||
<span class="font-sans text-xs text-gray-400">{timeAgo(reply.createdAt)}</span>
|
|
||||||
{#if wasEdited(reply)}
|
|
||||||
<span class="font-sans text-xs text-gray-400">
|
|
||||||
{m.comment_edited_label()}
|
|
||||||
{timeAgo(reply.updatedAt)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{reply.content}</p>
|
|
||||||
</div>
|
|
||||||
{#if canModify(reply)}
|
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
|
||||||
<button
|
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
||||||
onclick={() => startEdit(reply)}
|
|
||||||
>
|
|
||||||
{m.btn_edit()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
|
||||||
onclick={() => deleteComment(reply.id)}
|
|
||||||
>
|
|
||||||
{m.btn_delete()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<!-- Reply button only on the last reply -->
|
|
||||||
{#if ri === thread.replies.length - 1 && canComment}
|
|
||||||
<div class="mt-1">
|
|
||||||
<button
|
|
||||||
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
|
||||||
onclick={() => startReply(thread.id)}
|
|
||||||
>
|
|
||||||
{m.comment_btn_reply()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Reply textarea (shown when replyingTo === thread.id) -->
|
<!-- Reply compose box -->
|
||||||
{#if replyingTo === thread.id}
|
{#if replyingTo === thread.id}
|
||||||
<div class="mt-3 ml-6 flex flex-col gap-2">
|
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
bind:value={replyText}
|
||||||
|
bind:mentionCandidates={replyMentionCandidates}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={m.comment_placeholder()}
|
placeholder={m.comment_placeholder()}
|
||||||
bind:value={replyText}
|
disabled={posting}
|
||||||
></textarea>
|
onsubmit={() => postReply(thread.id)}
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||||
disabled={posting}
|
disabled={posting}
|
||||||
onclick={() => postReply(thread.id)}
|
onclick={() => postReply(thread.id)}
|
||||||
>
|
>
|
||||||
{m.comment_btn_post()}
|
{m.comment_btn_post()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||||
onclick={cancelReply}
|
onclick={cancelReply}
|
||||||
>
|
>
|
||||||
{m.btn_cancel()}
|
{m.btn_cancel()}
|
||||||
@@ -369,19 +361,21 @@ onMount(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- New top-level comment textarea -->
|
<!-- New top-level comment -->
|
||||||
{#if canComment}
|
{#if canComment}
|
||||||
<div class={comments.length > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<textarea
|
<MentionEditor
|
||||||
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
bind:value={newText}
|
||||||
|
bind:mentionCandidates={newMentionCandidates}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={m.comment_placeholder()}
|
placeholder={m.comment_placeholder()}
|
||||||
bind:value={newText}
|
disabled={posting}
|
||||||
></textarea>
|
onsubmit={postComment}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||||
disabled={posting || !newText.trim()}
|
disabled={posting || !newText.trim()}
|
||||||
onclick={postComment}
|
onclick={postComment}
|
||||||
>
|
>
|
||||||
|
|||||||
70
frontend/src/lib/components/CommentThread.svelte.spec.ts
Normal file
70
frontend/src/lib/components/CommentThread.svelte.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import CommentThread from './CommentThread.svelte';
|
||||||
|
import type { Comment } from '$lib/types';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeComment(id: string, content = 'Hello'): Comment {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
authorId: 'user-1',
|
||||||
|
authorName: 'Alice',
|
||||||
|
content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
replies: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
documentId: 'doc-1',
|
||||||
|
canComment: true,
|
||||||
|
currentUserId: 'user-1',
|
||||||
|
canAdmin: false
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CommentThread – empty state', () => {
|
||||||
|
it('shows empty state hint when there are no comments', async () => {
|
||||||
|
render(CommentThread, { ...baseProps, initialComments: [] });
|
||||||
|
await expect
|
||||||
|
.element(page.getByText('Noch keine Kommentare – starte die Diskussion!'))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show empty state hint when comments exist', async () => {
|
||||||
|
render(CommentThread, { ...baseProps, initialComments: [makeComment('c-1')] });
|
||||||
|
await expect
|
||||||
|
.element(page.getByText('Noch keine Kommentare – starte die Diskussion!'))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CommentThread – onCountChange', () => {
|
||||||
|
it('calls onCountChange with initial SSR count on mount', async () => {
|
||||||
|
const onCountChange = vi.fn();
|
||||||
|
render(CommentThread, {
|
||||||
|
...baseProps,
|
||||||
|
initialComments: [makeComment('c-1'), makeComment('c-2')],
|
||||||
|
onCountChange
|
||||||
|
});
|
||||||
|
expect(onCountChange).toHaveBeenCalledWith(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onCountChange with 0 when no initial comments', async () => {
|
||||||
|
const onCountChange = vi.fn();
|
||||||
|
render(CommentThread, { ...baseProps, initialComments: [], onCountChange });
|
||||||
|
expect(onCountChange).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts replies in the total', async () => {
|
||||||
|
const onCountChange = vi.fn();
|
||||||
|
const comment = { ...makeComment('c-1'), replies: [makeComment('r-1') as never] };
|
||||||
|
render(CommentThread, { ...baseProps, initialComments: [comment], onCountChange });
|
||||||
|
expect(onCountChange).toHaveBeenCalledWith(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
49
frontend/src/lib/components/DashboardMentions.svelte
Normal file
49
frontend/src/lib/components/DashboardMentions.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type NotificationDTO = {
|
||||||
|
id: string;
|
||||||
|
type: 'REPLY' | 'MENTION';
|
||||||
|
documentId?: string;
|
||||||
|
referenceId?: string;
|
||||||
|
annotationId?: string;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
actorName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mentions: NotificationDTO[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { mentions }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if mentions.length > 0}
|
||||||
|
<div data-testid="dashboard-mentions" class="rounded-sm border border-line bg-surface p-6">
|
||||||
|
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
|
{m.dashboard_notifications_heading()}
|
||||||
|
</h2>
|
||||||
|
{#each mentions as mention (mention.id)}
|
||||||
|
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
|
||||||
|
{#if mention.documentId}
|
||||||
|
<a
|
||||||
|
href={mention.annotationId
|
||||||
|
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
|
||||||
|
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
|
||||||
|
class="font-serif text-sm text-ink hover:text-ink-2"
|
||||||
|
>
|
||||||
|
{mention.actorName ?? ''}
|
||||||
|
{#if mention.type === 'MENTION'}<span class="ml-1 font-sans text-xs text-gray-400"
|
||||||
|
>{m.dashboard_notification_mentioned()}</span
|
||||||
|
>{:else}<span class="ml-1 font-sans text-xs text-gray-400"
|
||||||
|
>{m.dashboard_notification_replied()}</span
|
||||||
|
>{/if}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="font-serif text-sm text-ink">{mention.actorName ?? ''}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
68
frontend/src/lib/components/DashboardMentions.svelte.spec.ts
Normal file
68
frontend/src/lib/components/DashboardMentions.svelte.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import DashboardMentions from './DashboardMentions.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type NotificationDTO = {
|
||||||
|
id: string;
|
||||||
|
type: 'REPLY' | 'MENTION';
|
||||||
|
documentId?: string;
|
||||||
|
referenceId?: string;
|
||||||
|
annotationId?: string;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
actorName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeMention(overrides: Partial<NotificationDTO> = {}): NotificationDTO {
|
||||||
|
return {
|
||||||
|
id: 'notif-1',
|
||||||
|
type: 'MENTION',
|
||||||
|
documentId: 'doc-abc',
|
||||||
|
referenceId: 'comment-xyz',
|
||||||
|
read: false,
|
||||||
|
createdAt: '2026-01-15T10:00:00Z',
|
||||||
|
actorName: 'Anna Schmidt',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DashboardMentions', () => {
|
||||||
|
it('renders nothing when mentions list is empty', async () => {
|
||||||
|
render(DashboardMentions, { mentions: [] });
|
||||||
|
const widget = page.getByTestId('dashboard-mentions');
|
||||||
|
await expect.element(widget).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a heading when mentions are present', async () => {
|
||||||
|
render(DashboardMentions, { mentions: [makeMention()] });
|
||||||
|
const widget = page.getByTestId('dashboard-mentions');
|
||||||
|
await expect.element(widget).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds link with commentId param when no annotationId', async () => {
|
||||||
|
render(DashboardMentions, {
|
||||||
|
mentions: [makeMention({ documentId: 'doc-1', referenceId: 'cmt-1' })]
|
||||||
|
});
|
||||||
|
const link = page.getByRole('link');
|
||||||
|
await expect.element(link).toHaveAttribute('href', '/documents/doc-1?commentId=cmt-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds link with commentId and annotationId when annotationId is present', async () => {
|
||||||
|
render(DashboardMentions, {
|
||||||
|
mentions: [makeMention({ documentId: 'doc-2', referenceId: 'cmt-2', annotationId: 'ann-9' })]
|
||||||
|
});
|
||||||
|
const link = page.getByRole('link');
|
||||||
|
await expect
|
||||||
|
.element(link)
|
||||||
|
.toHaveAttribute('href', '/documents/doc-2?commentId=cmt-2&annotationId=ann-9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows actor name in each row', async () => {
|
||||||
|
render(DashboardMentions, { mentions: [makeMention({ actorName: 'Maria Müller' })] });
|
||||||
|
await expect.element(page.getByText('Maria Müller')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
frontend/src/lib/components/DashboardNeedsMetadata.svelte
Normal file
37
frontend/src/lib/components/DashboardNeedsMetadata.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type IncompleteDocumentDTO = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
incompleteDocs: IncompleteDocumentDTO[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { incompleteDocs }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if incompleteDocs.length > 0}
|
||||||
|
<div data-testid="dashboard-needs-metadata" class="rounded-sm border border-line bg-surface p-6">
|
||||||
|
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
|
{m.dashboard_needs_metadata_heading()}
|
||||||
|
</h2>
|
||||||
|
{#each incompleteDocs as doc (doc.id)}
|
||||||
|
<div class="flex items-center border-b border-line py-2 last:border-0">
|
||||||
|
<a
|
||||||
|
href="/enrich/{doc.id}"
|
||||||
|
class="font-serif text-sm text-ink hover:text-ink-2 hover:underline"
|
||||||
|
>
|
||||||
|
{doc.title}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="/enrich" class="font-sans text-xs text-ink-2 hover:text-ink"
|
||||||
|
>{m.dashboard_needs_metadata_show_all()}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import DashboardNeedsMetadata from './DashboardNeedsMetadata.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type IncompleteDocumentDTO = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeDoc(id: string, title: string): IncompleteDocumentDTO {
|
||||||
|
return { id, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DashboardNeedsMetadata', () => {
|
||||||
|
it('renders nothing when incompleteDocs is empty', async () => {
|
||||||
|
render(DashboardNeedsMetadata, { incompleteDocs: [] });
|
||||||
|
const widget = page.getByTestId('dashboard-needs-metadata');
|
||||||
|
await expect.element(widget).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the widget when incompleteDocs are present', async () => {
|
||||||
|
render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Taufschein')] });
|
||||||
|
const widget = page.getByTestId('dashboard-needs-metadata');
|
||||||
|
await expect.element(widget).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a link to /enrich/{id} for each document', async () => {
|
||||||
|
const docs = [makeDoc('d1', 'Taufschein'), makeDoc('d2', 'Heiratsurkunde')];
|
||||||
|
render(DashboardNeedsMetadata, { incompleteDocs: docs });
|
||||||
|
const links = page.getByRole('link');
|
||||||
|
await expect.element(links.nth(0)).toHaveAttribute('href', '/enrich/d1');
|
||||||
|
await expect.element(links.nth(1)).toHaveAttribute('href', '/enrich/d2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the document title in each row', async () => {
|
||||||
|
render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Sterbeurkunde 1930')] });
|
||||||
|
await expect.element(page.getByText('Sterbeurkunde 1930')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a "Alle anzeigen" link to /enrich', async () => {
|
||||||
|
render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Dok')] });
|
||||||
|
const allLink = page.getByRole('link', { name: /Alle anzeigen/i });
|
||||||
|
await expect.element(allLink).toHaveAttribute('href', '/enrich');
|
||||||
|
});
|
||||||
|
});
|
||||||
51
frontend/src/lib/components/DashboardRecentDocuments.svelte
Normal file
51
frontend/src/lib/components/DashboardRecentDocuments.svelte
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||||
|
|
||||||
|
type Document = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
sender?: { id: string; firstName: string; lastName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recentDocs: Document[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { recentDocs }: Props = $props();
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
return new Intl.DateTimeFormat(getLocale(), {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(new Date(dateStr));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if recentDocs.length > 0}
|
||||||
|
<div data-testid="dashboard-recent-docs" class="rounded-sm border border-line bg-surface p-6">
|
||||||
|
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||||
|
{m.dashboard_recent_heading()}
|
||||||
|
</h2>
|
||||||
|
{#each recentDocs as doc (doc.id)}
|
||||||
|
<div class="flex items-center justify-between border-b border-line py-2 last:border-0">
|
||||||
|
<a
|
||||||
|
href="/documents/{doc.id}"
|
||||||
|
class="font-serif text-sm text-ink hover:text-ink-2 hover:underline"
|
||||||
|
>
|
||||||
|
{doc.title}
|
||||||
|
</a>
|
||||||
|
{#if doc.updatedAt}
|
||||||
|
<span
|
||||||
|
data-testid="doc-date-{doc.id}"
|
||||||
|
class="ml-2 shrink-0 font-sans text-xs text-gray-400"
|
||||||
|
>
|
||||||
|
{formatDate(doc.updatedAt)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import DashboardRecentDocuments from './DashboardRecentDocuments.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type Document = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
sender?: { id: string; firstName: string; lastName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeDoc(id: string, title: string, updatedAt?: string): Document {
|
||||||
|
return { id, title, updatedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DashboardRecentDocuments', () => {
|
||||||
|
it('renders nothing when recentDocs is empty', async () => {
|
||||||
|
render(DashboardRecentDocuments, { recentDocs: [] });
|
||||||
|
const widget = page.getByTestId('dashboard-recent-docs');
|
||||||
|
await expect.element(widget).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the widget when recentDocs are present', async () => {
|
||||||
|
render(DashboardRecentDocuments, { recentDocs: [makeDoc('d1', 'Taufschein')] });
|
||||||
|
const widget = page.getByTestId('dashboard-recent-docs');
|
||||||
|
await expect.element(widget).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a link to /documents/{id} for each document', async () => {
|
||||||
|
const docs = [makeDoc('d1', 'Taufschein'), makeDoc('d2', 'Heiratsurkunde')];
|
||||||
|
render(DashboardRecentDocuments, { recentDocs: docs });
|
||||||
|
const links = page.getByRole('link');
|
||||||
|
await expect.element(links.nth(0)).toHaveAttribute('href', '/documents/d1');
|
||||||
|
await expect.element(links.nth(1)).toHaveAttribute('href', '/documents/d2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the document title in each row', async () => {
|
||||||
|
render(DashboardRecentDocuments, {
|
||||||
|
recentDocs: [makeDoc('d1', 'Sterbeurkunde 1930', '1930-05-12')]
|
||||||
|
});
|
||||||
|
await expect.element(page.getByText('Sterbeurkunde 1930')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats and displays the document date when present', async () => {
|
||||||
|
render(DashboardRecentDocuments, { recentDocs: [makeDoc('d1', 'Dok', '1945-04-20')] });
|
||||||
|
// The date should be visible in some formatted form
|
||||||
|
const widget = page.getByTestId('dashboard-recent-docs');
|
||||||
|
await expect.element(widget).toBeInTheDocument();
|
||||||
|
// Just verify the date element exists (not exact format due to locale)
|
||||||
|
const dateEl = page.getByTestId('doc-date-d1');
|
||||||
|
await expect.element(dateEl).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
frontend/src/lib/components/DashboardResumeStrip.svelte
Normal file
37
frontend/src/lib/components/DashboardResumeStrip.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface LastVisited {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastVisited = $state<LastVisited | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('familienarchiv.lastVisited');
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as LastVisited;
|
||||||
|
if (parsed?.id) {
|
||||||
|
lastVisited = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed JSON
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if lastVisited}
|
||||||
|
<div
|
||||||
|
data-testid="resume-strip"
|
||||||
|
class="flex items-center gap-2 rounded-sm border border-line bg-surface px-4 py-3 font-sans text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-ink-2">{m.dashboard_resume_label()}</span>
|
||||||
|
<a href="/documents/{lastVisited.id}" class="font-medium text-ink hover:underline">
|
||||||
|
{lastVisited.title || m.dashboard_resume_fallback()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import DashboardResumeStrip from './DashboardResumeStrip.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DashboardResumeStrip', () => {
|
||||||
|
it('renders nothing when no last-visited document in localStorage', async () => {
|
||||||
|
render(DashboardResumeStrip, {});
|
||||||
|
const strip = page.getByTestId('resume-strip');
|
||||||
|
await expect.element(strip).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the strip with link when localStorage has a document', async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'familienarchiv.lastVisited',
|
||||||
|
JSON.stringify({ id: 'doc-123', title: 'Geburtsurkunde 1920' })
|
||||||
|
);
|
||||||
|
render(DashboardResumeStrip, {});
|
||||||
|
const strip = page.getByTestId('resume-strip');
|
||||||
|
await expect.element(strip).toBeInTheDocument();
|
||||||
|
const link = page.getByRole('link', { name: /Geburtsurkunde 1920/ });
|
||||||
|
await expect.element(link).toBeInTheDocument();
|
||||||
|
await expect.element(link).toHaveAttribute('href', '/documents/doc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses title fallback text when title is empty', async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'familienarchiv.lastVisited',
|
||||||
|
JSON.stringify({ id: 'doc-456', title: '' })
|
||||||
|
);
|
||||||
|
render(DashboardResumeStrip, {});
|
||||||
|
const strip = page.getByTestId('resume-strip');
|
||||||
|
await expect.element(strip).toBeInTheDocument();
|
||||||
|
const link = page.getByRole('link');
|
||||||
|
await expect.element(link).toHaveAttribute('href', '/documents/doc-456');
|
||||||
|
});
|
||||||
|
});
|
||||||
193
frontend/src/lib/components/DocumentBottomPanel.svelte
Normal file
193
frontend/src/lib/components/DocumentBottomPanel.svelte
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import PanelMetadata from './PanelMetadata.svelte';
|
||||||
|
import PanelTranscription from './PanelTranscription.svelte';
|
||||||
|
import PanelDiscussion from './PanelDiscussion.svelte';
|
||||||
|
import PanelHistory from './PanelHistory.svelte';
|
||||||
|
import type { Comment, DocumentPanelTab } from '$lib/types';
|
||||||
|
|
||||||
|
type Doc = {
|
||||||
|
id: string;
|
||||||
|
title?: string | null;
|
||||||
|
documentDate?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
documentLocation?: string | null;
|
||||||
|
tags?: { id: string; name: string }[] | null;
|
||||||
|
sender?: { id: string; firstName: string; lastName: string; alias?: string | null } | null;
|
||||||
|
receivers?: { id: string; firstName: string; lastName: string }[] | null;
|
||||||
|
summary?: string | null;
|
||||||
|
transcription?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
doc: Doc;
|
||||||
|
comments: Comment[];
|
||||||
|
canComment: boolean;
|
||||||
|
currentUserId: string | null;
|
||||||
|
canAdmin: boolean;
|
||||||
|
open: boolean;
|
||||||
|
height: number;
|
||||||
|
activeTab: DocumentPanelTab;
|
||||||
|
targetCommentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
doc,
|
||||||
|
comments,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
open = $bindable(),
|
||||||
|
height = $bindable(),
|
||||||
|
activeTab = $bindable(),
|
||||||
|
targetCommentId = null
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
||||||
|
|
||||||
|
let isDragging = $state(false);
|
||||||
|
let dragStartY = 0;
|
||||||
|
let dragStartHeight = 0;
|
||||||
|
|
||||||
|
function fullHeight() {
|
||||||
|
const topbar = document.querySelector('[data-topbar]');
|
||||||
|
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTab(tab: DocumentPanelTab) {
|
||||||
|
activeTab = tab;
|
||||||
|
if (!open) {
|
||||||
|
open = true;
|
||||||
|
if (height <= MIN_HEIGHT) height = fullHeight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStart(e: PointerEvent) {
|
||||||
|
isDragging = true;
|
||||||
|
dragStartY = e.clientY;
|
||||||
|
dragStartHeight = open ? height : MIN_HEIGHT;
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragMove(e: PointerEvent) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
|
||||||
|
const newHeight = dragStartHeight + delta;
|
||||||
|
const maxHeight = fullHeight();
|
||||||
|
|
||||||
|
if (newHeight <= MIN_HEIGHT + 20) {
|
||||||
|
// collapsed past threshold → close
|
||||||
|
open = false;
|
||||||
|
} else {
|
||||||
|
open = true;
|
||||||
|
height = Math.max(80, Math.min(newHeight, maxHeight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { id: DocumentPanelTab; label: () => string }[] = [
|
||||||
|
{ id: 'metadata', label: m.doc_panel_tab_metadata },
|
||||||
|
{ id: 'transcription', label: m.doc_panel_tab_transcription },
|
||||||
|
{ id: 'discussion', label: m.doc_panel_tab_discussion },
|
||||||
|
{ id: 'history', label: m.doc_panel_tab_history }
|
||||||
|
];
|
||||||
|
|
||||||
|
const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
||||||
|
|
||||||
|
let discussionCount = $state((() => comments.reduce((s, c) => s + 1 + c.replies.length, 0))());
|
||||||
|
|
||||||
|
function handleCountChange(count: number) {
|
||||||
|
discussionCount = count;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="z-30 flex shrink-0 flex-col border-t border-line bg-surface shadow-[0_-4px_16px_rgba(0,0,0,0.08)]"
|
||||||
|
style="height: {panelHeight}px"
|
||||||
|
data-testid="bottom-panel"
|
||||||
|
>
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<div
|
||||||
|
class="flex h-2 shrink-0 cursor-ns-resize items-center justify-center bg-surface"
|
||||||
|
style="touch-action: none"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label="Panel resize"
|
||||||
|
onpointerdown={onDragStart}
|
||||||
|
onpointermove={onDragMove}
|
||||||
|
onpointerup={onDragEnd}
|
||||||
|
onpointercancel={onDragEnd}
|
||||||
|
>
|
||||||
|
<div class="h-1 w-12 rounded-full bg-line"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="flex shrink-0 items-center border-b border-line bg-surface">
|
||||||
|
<!-- Scrollable tabs area — hides scrollbar visually -->
|
||||||
|
<div
|
||||||
|
class="flex flex-1 items-center overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{#each tabs as tab (tab.id)}
|
||||||
|
<button
|
||||||
|
onclick={() => openTab(tab.id)}
|
||||||
|
class="mr-1 shrink-0 px-3 py-2.5 font-sans text-xs font-medium transition-colors {activeTab === tab.id && open
|
||||||
|
? 'border-b-2 border-primary text-ink'
|
||||||
|
: 'text-ink-3 hover:text-ink'}"
|
||||||
|
aria-pressed={activeTab === tab.id && open}
|
||||||
|
>
|
||||||
|
{tab.label()}
|
||||||
|
{#if tab.id === 'discussion'}
|
||||||
|
<span
|
||||||
|
data-testid="discussion-count-badge"
|
||||||
|
class="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 font-sans text-[10px] font-bold text-primary-fg"
|
||||||
|
>{discussionCount}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<button
|
||||||
|
onclick={closePanel}
|
||||||
|
data-testid="panel-close-btn"
|
||||||
|
aria-label="Panel schließen"
|
||||||
|
class="mr-2 shrink-0 rounded p-1.5 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab content -->
|
||||||
|
{#if open}
|
||||||
|
<div class="flex-1 overflow-y-auto" data-testid="bottom-panel-content">
|
||||||
|
{#if activeTab === 'metadata'}
|
||||||
|
<PanelMetadata doc={doc} />
|
||||||
|
{:else if activeTab === 'transcription'}
|
||||||
|
<PanelTranscription doc={doc} />
|
||||||
|
{:else if activeTab === 'discussion'}
|
||||||
|
<PanelDiscussion
|
||||||
|
documentId={doc.id}
|
||||||
|
initialComments={comments}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
targetCommentId={targetCommentId}
|
||||||
|
onCountChange={handleCountChange}
|
||||||
|
/>
|
||||||
|
{:else if activeTab === 'history'}
|
||||||
|
<PanelHistory documentId={doc.id} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user