Compare commits
41 Commits
feat/issue
...
ba307e991b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba307e991b | ||
|
|
3547a3d809 | ||
|
|
322c418321 | ||
|
|
9764ada854 | ||
|
|
1757b01af1 | ||
|
|
021a0c6cb3 | ||
|
|
31e7d97c30 | ||
|
|
ca0d539972 | ||
|
|
27e8c96c49 | ||
|
|
b3d49b28d7 | ||
|
|
26f1aeaa9d | ||
|
|
1081f5d263 | ||
|
|
4f2880a61a | ||
|
|
e37351f5c2 | ||
|
|
332d81975f | ||
|
|
b5455066c9 | ||
|
|
2df46b71f3 | ||
|
|
34b6a8a220 | ||
|
|
b6b9235dd8 | ||
|
|
7603c8d936 | ||
|
|
a822479535 | ||
|
|
58358e845d | ||
|
|
fcd4a41ba1 | ||
|
|
b6bf24db60 | ||
|
|
44209048a2 | ||
|
|
f67f5330ce | ||
|
|
fb658e7647 | ||
|
|
7618558895 | ||
|
|
94f63c4550 | ||
|
|
8052131576 | ||
|
|
2556e7f5c8 | ||
|
|
ecc4d1aa67 | ||
|
|
896d34cfcd | ||
|
|
a4e184d939 | ||
|
|
e1b5c1b15c | ||
|
|
5099dfa424 | ||
|
|
d9be001f1f | ||
|
|
671d05acac | ||
|
|
25afed0d65 | ||
|
|
a026d8bb05 | ||
|
|
1746cdd161 |
@@ -79,7 +79,6 @@ jobs:
|
|||||||
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
|
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
|
||||||
POSTGRES_USER=archiv
|
POSTGRES_USER=archiv
|
||||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||||
VITE_SENTRY_DSN=${{ secrets.VITE_SENTRY_DSN }}
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Verify backend /import:ro mount is wired
|
- name: Verify backend /import:ro mount is wired
|
||||||
|
|||||||
@@ -25,14 +25,11 @@ import java.util.UUID;
|
|||||||
@NamedEntityGraph(name = "Document.full", attributeNodes = {
|
@NamedEntityGraph(name = "Document.full", attributeNodes = {
|
||||||
@NamedAttributeNode("sender"),
|
@NamedAttributeNode("sender"),
|
||||||
@NamedAttributeNode("receivers"),
|
@NamedAttributeNode("receivers"),
|
||||||
@NamedAttributeNode("tags"),
|
@NamedAttributeNode("tags")
|
||||||
@NamedAttributeNode("trainingLabels")
|
|
||||||
})
|
})
|
||||||
@NamedEntityGraph(name = "Document.list", attributeNodes = {
|
@NamedEntityGraph(name = "Document.list", attributeNodes = {
|
||||||
@NamedAttributeNode("sender"),
|
@NamedAttributeNode("sender"),
|
||||||
@NamedAttributeNode("receivers"),
|
@NamedAttributeNode("tags")
|
||||||
@NamedAttributeNode("tags"),
|
|
||||||
@NamedAttributeNode("trainingLabels")
|
|
||||||
})
|
})
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "documents")
|
@Table(name = "documents")
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public class TranscriptionBlockController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public TranscriptionBlock createBlock(
|
public TranscriptionBlock createBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
||||||
@@ -53,7 +53,7 @@ public class TranscriptionBlockController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{blockId}")
|
@PutMapping("/{blockId}")
|
||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public TranscriptionBlock updateBlock(
|
public TranscriptionBlock updateBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID blockId,
|
@PathVariable UUID blockId,
|
||||||
@@ -65,7 +65,7 @@ public class TranscriptionBlockController {
|
|||||||
|
|
||||||
@DeleteMapping("/{blockId}")
|
@DeleteMapping("/{blockId}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public void deleteBlock(
|
public void deleteBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID blockId) {
|
@PathVariable UUID blockId) {
|
||||||
@@ -73,7 +73,7 @@ public class TranscriptionBlockController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/reorder")
|
@PutMapping("/reorder")
|
||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public List<TranscriptionBlock> reorderBlocks(
|
public List<TranscriptionBlock> reorderBlocks(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@RequestBody ReorderTranscriptionBlocksDTO dto) {
|
@RequestBody ReorderTranscriptionBlocksDTO dto) {
|
||||||
@@ -82,7 +82,7 @@ public class TranscriptionBlockController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{blockId}/review")
|
@PutMapping("/{blockId}/review")
|
||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public TranscriptionBlock reviewBlock(
|
public TranscriptionBlock reviewBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID blockId,
|
@PathVariable UUID blockId,
|
||||||
@@ -92,7 +92,7 @@ public class TranscriptionBlockController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/review-all")
|
@PutMapping("/review-all")
|
||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public List<TranscriptionBlock> markAllBlocksReviewed(
|
public List<TranscriptionBlock> markAllBlocksReviewed(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
|||||||
@@ -56,17 +56,9 @@ public class MassImportService {
|
|||||||
|
|
||||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||||
|
|
||||||
public enum SkipReason {
|
|
||||||
INVALID_FILENAME_PATH_TRAVERSAL,
|
|
||||||
INVALID_PDF_SIGNATURE,
|
|
||||||
FILE_READ_ERROR,
|
|
||||||
ALREADY_EXISTS,
|
|
||||||
S3_UPLOAD_FAILED
|
|
||||||
}
|
|
||||||
|
|
||||||
public record SkippedFile(
|
public record SkippedFile(
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) SkipReason reason
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public record ImportStatus(
|
public record ImportStatus(
|
||||||
@@ -299,11 +291,6 @@ public class MassImportService {
|
|||||||
if (index.isBlank()) continue;
|
if (index.isBlank()) continue;
|
||||||
|
|
||||||
String filename = index.contains(".") ? index : index + ".pdf";
|
String filename = index.contains(".") ? index : index + ".pdf";
|
||||||
if (!isValidImportFilename(filename)) {
|
|
||||||
log.warn("Skipping import row {}: filename rejected — {}", i, filename);
|
|
||||||
skippedFiles.add(new SkippedFile(filename, SkipReason.INVALID_FILENAME_PATH_TRAVERSAL));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Optional<File> fileOnDisk = findFileRecursive(filename);
|
Optional<File> fileOnDisk = findFileRecursive(filename);
|
||||||
if (fileOnDisk.isEmpty()) {
|
if (fileOnDisk.isEmpty()) {
|
||||||
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
|
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
|
||||||
@@ -313,17 +300,17 @@ public class MassImportService {
|
|||||||
try {
|
try {
|
||||||
if (!isPdfMagicBytes(fileOnDisk.get())) {
|
if (!isPdfMagicBytes(fileOnDisk.get())) {
|
||||||
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
|
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
|
||||||
skippedFiles.add(new SkippedFile(filename, SkipReason.INVALID_PDF_SIGNATURE));
|
skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
|
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
|
||||||
skippedFiles.add(new SkippedFile(filename, SkipReason.FILE_READ_ERROR));
|
skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<SkipReason> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
|
Optional<String> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
|
||||||
if (skipReason.isPresent()) {
|
if (skipReason.isPresent()) {
|
||||||
skippedFiles.add(new SkippedFile(filename, skipReason.get()));
|
skippedFiles.add(new SkippedFile(filename, skipReason.get()));
|
||||||
} else {
|
} else {
|
||||||
@@ -333,23 +320,6 @@ public class MassImportService {
|
|||||||
return new ProcessResult(processed, skippedFiles);
|
return new ProcessResult(processed, skippedFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isValidImportFilename(String filename) {
|
|
||||||
if (filename == null || filename.isBlank()) return false;
|
|
||||||
if (filename.contains("/")) return false;
|
|
||||||
if (filename.contains("\\")) return false;
|
|
||||||
if (filename.contains("∕")) return false; // U+2215 DIVISION SLASH
|
|
||||||
if (filename.contains("/")) return false; // U+FF0F FULLWIDTH SOLIDUS
|
|
||||||
if (filename.contains("⧵")) return false; // U+29F5 REVERSE SOLIDUS OPERATOR
|
|
||||||
if (filename.contains("..")) return false;
|
|
||||||
if (filename.equals(".")) return false;
|
|
||||||
if (filename.contains("\0")) return false;
|
|
||||||
// Paths.get() is safe here on Linux for all inputs that passed the checks above;
|
|
||||||
// it may throw InvalidPathException for OS-specific illegal chars on Windows,
|
|
||||||
// but those are not reachable in production.
|
|
||||||
if (Paths.get(filename).isAbsolute()) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// package-private: Mockito spy in tests can override to inject IOException
|
// package-private: Mockito spy in tests can override to inject IOException
|
||||||
InputStream openFileStream(File file) throws IOException {
|
InputStream openFileStream(File file) throws IOException {
|
||||||
return new FileInputStream(file);
|
return new FileInputStream(file);
|
||||||
@@ -372,11 +342,11 @@ public class MassImportService {
|
|||||||
* @return empty Optional on success; an Optional containing the skip reason on failure/skip.
|
* @return empty Optional on success; an Optional containing the skip reason on failure/skip.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
protected Optional<SkipReason> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
protected Optional<String> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
||||||
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
|
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
|
||||||
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
|
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
|
||||||
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
|
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
|
||||||
return Optional.of(SkipReason.ALREADY_EXISTS);
|
return Optional.of("ALREADY_EXISTS");
|
||||||
}
|
}
|
||||||
|
|
||||||
String archiveBox = getCell(cells, colBox);
|
String archiveBox = getCell(cells, colBox);
|
||||||
@@ -412,7 +382,7 @@ public class MassImportService {
|
|||||||
status = DocumentStatus.UPLOADED;
|
status = DocumentStatus.UPLOADED;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
|
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
|
||||||
return Optional.of(SkipReason.S3_UPLOAD_FAILED);
|
return Optional.of("S3_UPLOAD_FAILED");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,18 +460,11 @@ public class MassImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Optional<File> findFileRecursive(String filename) {
|
private Optional<File> findFileRecursive(String filename) {
|
||||||
File baseDir = new File(importDir);
|
try (Stream<Path> walk = Files.walk(Paths.get(importDir))) {
|
||||||
try (Stream<Path> walk = Files.walk(baseDir.toPath())) {
|
return walk.filter(p -> !Files.isDirectory(p))
|
||||||
Optional<Path> match = walk.filter(p -> !Files.isDirectory(p))
|
|
||||||
.filter(p -> p.getFileName().toString().equals(filename))
|
.filter(p -> p.getFileName().toString().equals(filename))
|
||||||
|
.map(Path::toFile)
|
||||||
.findFirst();
|
.findFirst();
|
||||||
if (match.isEmpty()) return Optional.empty();
|
|
||||||
File candidate = match.get().toFile();
|
|
||||||
String baseDirCanonical = baseDir.getCanonicalPath();
|
|
||||||
if (!candidate.getCanonicalPath().startsWith(baseDirCanonical + File.separator)) {
|
|
||||||
throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Path escape detected: " + candidate);
|
|
||||||
}
|
|
||||||
return Optional.of(candidate);
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,10 +154,10 @@ class MassImportServiceTest {
|
|||||||
.build();
|
.build();
|
||||||
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
|
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
Optional<MassImportService.SkipReason> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
Optional<String> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
||||||
|
|
||||||
verify(documentService, never()).save(any());
|
verify(documentService, never()).save(any());
|
||||||
assertThat(result).isPresent().contains(MassImportService.SkipReason.ALREADY_EXISTS);
|
assertThat(result).isPresent().contains("ALREADY_EXISTS");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── importSingleDocument — already-exists guard fires before file I/O ─────
|
// ─── importSingleDocument — already-exists guard fires before file I/O ─────
|
||||||
@@ -179,10 +179,10 @@ class MassImportServiceTest {
|
|||||||
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||||
Files.write(physicalFile, pdfHeader);
|
Files.write(physicalFile, pdfHeader);
|
||||||
|
|
||||||
Optional<MassImportService.SkipReason> result = service.importSingleDocument(
|
Optional<String> result = service.importSingleDocument(
|
||||||
minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");
|
minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");
|
||||||
|
|
||||||
assertThat(result).isPresent().contains(MassImportService.SkipReason.ALREADY_EXISTS);
|
assertThat(result).isPresent().contains("ALREADY_EXISTS");
|
||||||
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
verify(documentService, never()).save(any());
|
verify(documentService, never()).save(any());
|
||||||
}
|
}
|
||||||
@@ -204,7 +204,7 @@ class MassImportServiceTest {
|
|||||||
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
assertThat(service.getStatus().skippedFiles())
|
assertThat(service.getStatus().skippedFiles())
|
||||||
.extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
|
.extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
|
||||||
.containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", MassImportService.SkipReason.S3_UPLOAD_FAILED));
|
.containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", "S3_UPLOAD_FAILED"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -223,7 +223,7 @@ class MassImportServiceTest {
|
|||||||
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||||
assertThat(service.getStatus().skippedFiles())
|
assertThat(service.getStatus().skippedFiles())
|
||||||
.extracting(MassImportService.SkippedFile::reason)
|
.extracting(MassImportService.SkippedFile::reason)
|
||||||
.containsExactly(MassImportService.SkipReason.ALREADY_EXISTS);
|
.containsExactly("ALREADY_EXISTS");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── importSingleDocument — create new document (metadata only) ───────────
|
// ─── importSingleDocument — create new document (metadata only) ───────────
|
||||||
@@ -283,11 +283,11 @@ class MassImportServiceTest {
|
|||||||
doThrow(new RuntimeException("S3 error"))
|
doThrow(new RuntimeException("S3 error"))
|
||||||
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
|
||||||
Optional<MassImportService.SkipReason> result = service.importSingleDocument(
|
Optional<String> result = service.importSingleDocument(
|
||||||
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
|
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
|
||||||
|
|
||||||
verify(documentService, never()).save(any());
|
verify(documentService, never()).save(any());
|
||||||
assertThat(result).isPresent().contains(MassImportService.SkipReason.S3_UPLOAD_FAILED);
|
assertThat(result).isPresent().contains("S3_UPLOAD_FAILED");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── importSingleDocument — sender handling ───────────────────────────────
|
// ─── importSingleDocument — sender handling ───────────────────────────────
|
||||||
@@ -438,110 +438,6 @@ class MassImportServiceTest {
|
|||||||
verify(documentService).findByOriginalFilename("doc002.pdf");
|
verify(documentService).findByOriginalFilename("doc002.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── isValidImportFilename — security regression — do not remove ─────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameIsNull() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", (String) null);
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameIsBlank() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", " ");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameContainsForwardSlash() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "etc/passwd");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameContainsBackslash() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "..\\etc\\passwd");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameContainsDotDot() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "doc..evil.pdf");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameIsDotDot() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "..");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameIsAbsolutePath() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "/etc/passwd");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameContainsNullByte() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "file\0.pdf");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsTrue_whenFilenameIsPlainBasename() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "document.pdf");
|
|
||||||
assertThat(result).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameContainsUnicodeDivisionSlash() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foo∕bar.pdf");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameContainsFullwidthSlash() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foo/bar.pdf");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsFalse_whenFilenameContainsUnicodeReverseSolidus() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foo⧵bar.pdf");
|
|
||||||
assertThat(result).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsTrue_whenFilenameHasLeadingDot() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", ".hidden.pdf");
|
|
||||||
assertThat(result).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void isValidImportFilename_returnsTrue_whenFilenameHasSpaces() {
|
|
||||||
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "Brief an Oma.pdf");
|
|
||||||
assertThat(result).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void processRows_skipsRowAndContinues_whenFilenameIsPathTraversal() {
|
|
||||||
when(documentService.findByOriginalFilename("legitimate.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
List<List<String>> rows = List.of(
|
|
||||||
List.of("header"),
|
|
||||||
minimalCells("../evil"), // row 1: path traversal — should be skipped
|
|
||||||
minimalCells("legitimate.pdf") // row 2: valid — should be processed
|
|
||||||
);
|
|
||||||
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
|
||||||
|
|
||||||
assertThat(result.processed()).isEqualTo(1);
|
|
||||||
assertThat(result.skippedFiles())
|
|
||||||
.extracting(MassImportService.SkippedFile::reason)
|
|
||||||
.containsExactly(MassImportService.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── importSingleDocument — non-blank optional fields ────────────────────
|
// ─── importSingleDocument — non-blank optional fields ────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -755,22 +651,7 @@ class MassImportServiceTest {
|
|||||||
assertThat(spyService.getStatus().skipped()).isEqualTo(1);
|
assertThat(spyService.getStatus().skipped()).isEqualTo(1);
|
||||||
assertThat(spyService.getStatus().skippedFiles())
|
assertThat(spyService.getStatus().skippedFiles())
|
||||||
.extracting(MassImportService.SkippedFile::reason)
|
.extracting(MassImportService.SkippedFile::reason)
|
||||||
.containsExactly(MassImportService.SkipReason.FILE_READ_ERROR);
|
.containsExactly("FILE_READ_ERROR");
|
||||||
}
|
|
||||||
|
|
||||||
// ─── findFileRecursive — symlink escape security regression — do not remove ─
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void findFileRecursive_throwsDomainException_whenSymlinkEscapesImportDir(
|
|
||||||
@TempDir Path importDirPath, @TempDir Path outsideDir) throws Exception {
|
|
||||||
Path outsideFile = outsideDir.resolve("secret.pdf");
|
|
||||||
Files.writeString(outsideFile, "sensitive content");
|
|
||||||
Files.createSymbolicLink(importDirPath.resolve("secret.pdf"), outsideFile);
|
|
||||||
|
|
||||||
ReflectionTestUtils.setField(service, "importDir", importDirPath.toString());
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "findFileRecursive", "secret.pdf"))
|
|
||||||
.isInstanceOf(DomainException.class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── readOds — XXE security regression ───────────────────────────────────
|
// ─── readOds — XXE security regression ───────────────────────────────────
|
||||||
|
|||||||
@@ -252,8 +252,6 @@ services:
|
|||||||
OTEL_METRICS_EXPORTER: none
|
OTEL_METRICS_EXPORTER: none
|
||||||
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
|
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
|
||||||
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
|
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
|
||||||
SENTRY_DSN: ${SENTRY_DSN:-}
|
|
||||||
LOGGING_STRUCTURED_FORMAT_CONSOLE: ecs
|
|
||||||
networks:
|
networks:
|
||||||
- archiv-net
|
- archiv-net
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -268,10 +266,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
target: production
|
target: production
|
||||||
args:
|
|
||||||
# Vite build-time variable — baked into the JS bundle at build time.
|
|
||||||
# Empty default so deploys succeed before the secret is configured.
|
|
||||||
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ CMD ["npm", "run", "dev"]
|
|||||||
# Compiles the SvelteKit Node-adapter output to /app/build.
|
# Compiles the SvelteKit Node-adapter output to /app/build.
|
||||||
FROM node:20.19.0-alpine3.21 AS build
|
FROM node:20.19.0-alpine3.21 AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# VITE_SENTRY_DSN is a build-time variable — Vite bakes it into the bundle.
|
|
||||||
# Passed via docker-compose build.args; empty string disables the SDK.
|
|
||||||
ARG VITE_SENTRY_DSN
|
|
||||||
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -526,7 +526,6 @@
|
|||||||
"notification_filter_unread": "Ungelesen",
|
"notification_filter_unread": "Ungelesen",
|
||||||
"notification_filter_mention": "Erwähnung",
|
"notification_filter_mention": "Erwähnung",
|
||||||
"notification_filter_reply": "Antwort",
|
"notification_filter_reply": "Antwort",
|
||||||
"notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
|
|
||||||
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
|
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
|
||||||
"notification_load_more": "Ältere laden",
|
"notification_load_more": "Ältere laden",
|
||||||
"notification_empty_history": "Keine Benachrichtigungen",
|
"notification_empty_history": "Keine Benachrichtigungen",
|
||||||
@@ -638,9 +637,6 @@
|
|||||||
"transcription_block_review": "Als geprüft markieren",
|
"transcription_block_review": "Als geprüft markieren",
|
||||||
"transcription_block_unreview": "Markierung aufheben",
|
"transcription_block_unreview": "Markierung aufheben",
|
||||||
"transcription_reviewed_count": "{reviewed} von {total} geprüft",
|
"transcription_reviewed_count": "{reviewed} von {total} geprüft",
|
||||||
"transcription_mark_all_reviewed": "Alle als fertig markieren",
|
|
||||||
"transcription_mark_all_reviewed_disabled": "Alle Blöcke sind bereits als fertig markiert",
|
|
||||||
"transcription_mark_all_reviewed_error": "Markierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
|
||||||
"training_ocr_heading": "Kurrent-Erkennung trainieren",
|
"training_ocr_heading": "Kurrent-Erkennung trainieren",
|
||||||
"training_ocr_description": "Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für Kurrentschrift zu verbessern.",
|
"training_ocr_description": "Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für Kurrentschrift zu verbessern.",
|
||||||
"training_ocr_blocks_ready": "{blocks} geprüfte Blöcke bereit / {docs} Dokumente",
|
"training_ocr_blocks_ready": "{blocks} geprüfte Blöcke bereit / {docs} Dokumente",
|
||||||
|
|||||||
@@ -526,7 +526,6 @@
|
|||||||
"notification_filter_unread": "Unread",
|
"notification_filter_unread": "Unread",
|
||||||
"notification_filter_mention": "Mention",
|
"notification_filter_mention": "Mention",
|
||||||
"notification_filter_reply": "Reply",
|
"notification_filter_reply": "Reply",
|
||||||
"notification_error_generic": "Action failed. Please try again.",
|
|
||||||
"notification_mark_all_read_aria": "Mark all notifications as read",
|
"notification_mark_all_read_aria": "Mark all notifications as read",
|
||||||
"notification_load_more": "Load older",
|
"notification_load_more": "Load older",
|
||||||
"notification_empty_history": "No notifications",
|
"notification_empty_history": "No notifications",
|
||||||
@@ -638,9 +637,6 @@
|
|||||||
"transcription_block_review": "Mark as reviewed",
|
"transcription_block_review": "Mark as reviewed",
|
||||||
"transcription_block_unreview": "Unmark as reviewed",
|
"transcription_block_unreview": "Unmark as reviewed",
|
||||||
"transcription_reviewed_count": "{reviewed} of {total} reviewed",
|
"transcription_reviewed_count": "{reviewed} of {total} reviewed",
|
||||||
"transcription_mark_all_reviewed": "Mark all as reviewed",
|
|
||||||
"transcription_mark_all_reviewed_disabled": "All blocks are already marked as reviewed",
|
|
||||||
"transcription_mark_all_reviewed_error": "Failed to mark all as reviewed. Please try again.",
|
|
||||||
"training_ocr_heading": "Train Kurrent recognition",
|
"training_ocr_heading": "Train Kurrent recognition",
|
||||||
"training_ocr_description": "Start a new training run using the reviewed OCR blocks to improve recognition accuracy for Kurrent script.",
|
"training_ocr_description": "Start a new training run using the reviewed OCR blocks to improve recognition accuracy for Kurrent script.",
|
||||||
"training_ocr_blocks_ready": "{blocks} reviewed blocks ready / {docs} documents",
|
"training_ocr_blocks_ready": "{blocks} reviewed blocks ready / {docs} documents",
|
||||||
|
|||||||
@@ -526,7 +526,6 @@
|
|||||||
"notification_filter_unread": "No leídas",
|
"notification_filter_unread": "No leídas",
|
||||||
"notification_filter_mention": "Mención",
|
"notification_filter_mention": "Mención",
|
||||||
"notification_filter_reply": "Respuesta",
|
"notification_filter_reply": "Respuesta",
|
||||||
"notification_error_generic": "La acción ha fallado. Por favor, inténtalo de nuevo.",
|
|
||||||
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
|
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
|
||||||
"notification_load_more": "Cargar anteriores",
|
"notification_load_more": "Cargar anteriores",
|
||||||
"notification_empty_history": "Sin notificaciones",
|
"notification_empty_history": "Sin notificaciones",
|
||||||
@@ -638,9 +637,6 @@
|
|||||||
"transcription_block_review": "Marcar como revisado",
|
"transcription_block_review": "Marcar como revisado",
|
||||||
"transcription_block_unreview": "Desmarcar como revisado",
|
"transcription_block_unreview": "Desmarcar como revisado",
|
||||||
"transcription_reviewed_count": "{reviewed} de {total} revisados",
|
"transcription_reviewed_count": "{reviewed} de {total} revisados",
|
||||||
"transcription_mark_all_reviewed": "Marcar todo como revisado",
|
|
||||||
"transcription_mark_all_reviewed_disabled": "Todos los bloques ya están marcados como revisados",
|
|
||||||
"transcription_mark_all_reviewed_error": "Error al marcar como revisado. Intente de nuevo.",
|
|
||||||
"training_ocr_heading": "Entrenar reconocimiento Kurrent",
|
"training_ocr_heading": "Entrenar reconocimiento Kurrent",
|
||||||
"training_ocr_description": "Inicia un nuevo entrenamiento con los bloques OCR revisados para mejorar la precisión de reconocimiento del script Kurrent.",
|
"training_ocr_description": "Inicia un nuevo entrenamiento con los bloques OCR revisados para mejorar la precisión de reconocimiento del script Kurrent.",
|
||||||
"training_ocr_blocks_ready": "{blocks} bloques revisados listos / {docs} documentos",
|
"training_ocr_blocks_ready": "{blocks} bloques revisados listos / {docs} documentos",
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
// Shared mock for SvelteKit's $app/navigation virtual module.
|
|
||||||
// Activated by calling `vi.mock('$app/navigation')` (no factory) in a spec.
|
|
||||||
// Per ADR-012: eliminating per-spec factory bodies removes 36 birpc-race surface
|
|
||||||
// points; the unified mock keeps every nav export available as a vi.fn().
|
|
||||||
//
|
|
||||||
// IMPORTANT: consuming specs MUST call `vi.clearAllMocks()` (or per-mock
|
|
||||||
// `mockClear()`) in `afterEach` — otherwise call counts leak between tests.
|
|
||||||
import { vi } from 'vitest';
|
|
||||||
|
|
||||||
export const goto = vi.fn(async () => {});
|
|
||||||
export const invalidate = vi.fn(async () => {});
|
|
||||||
export const invalidateAll = vi.fn(async () => {});
|
|
||||||
export const beforeNavigate = vi.fn();
|
|
||||||
export const afterNavigate = vi.fn();
|
|
||||||
export const preloadCode = vi.fn(async () => {});
|
|
||||||
export const preloadData = vi.fn(async () => {});
|
|
||||||
export const pushState = vi.fn();
|
|
||||||
export const replaceState = vi.fn();
|
|
||||||
export const disableScrollHandling = vi.fn();
|
|
||||||
export const onNavigate = vi.fn(() => () => {});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import { relativeTime } from '$lib/shared/utils/time';
|
import { relativeTime } from '$lib/shared/utils/time';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||||
@@ -7,13 +6,11 @@ import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
unread: NotificationItem[];
|
unread: NotificationItem[];
|
||||||
optimisticMarkRead: (id: string) => void;
|
onMarkRead: (n: NotificationItem) => void;
|
||||||
optimisticMarkAllRead: () => void;
|
onMarkAllRead: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props();
|
const { unread, onMarkRead, onMarkAllRead }: Props = $props();
|
||||||
|
|
||||||
let errorMessage: string | null = $state(null);
|
|
||||||
|
|
||||||
function verb(type: NotificationItem['type'], actor: string): string {
|
function verb(type: NotificationItem['type'], actor: string): string {
|
||||||
return type === 'REPLY'
|
return type === 'REPLY'
|
||||||
@@ -27,9 +24,6 @@ function href(n: NotificationItem): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="rounded-sm border border-line bg-surface p-5">
|
<section class="rounded-sm border border-line bg-surface p-5">
|
||||||
{#if errorMessage}
|
|
||||||
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
|
|
||||||
{/if}
|
|
||||||
{#if unread.length === 0}
|
{#if unread.length === 0}
|
||||||
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
|
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
|
||||||
<svg
|
<svg
|
||||||
@@ -72,28 +66,14 @@ function href(n: NotificationItem): string {
|
|||||||
{m.chronik_for_you_count({ count: unread.length })}
|
{m.chronik_for_you_count({ count: unread.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<button
|
||||||
action="/aktivitaeten?/mark-all-read"
|
type="button"
|
||||||
method="POST"
|
data-testid="chronik-mark-all-read"
|
||||||
use:enhance={() => {
|
onclick={onMarkAllRead}
|
||||||
errorMessage = null;
|
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||||
optimisticMarkAllRead();
|
|
||||||
return async ({ result, update }) => {
|
|
||||||
if (result.type === 'failure' || result.type === 'error') {
|
|
||||||
errorMessage = m.notification_error_generic();
|
|
||||||
await update({ reset: false, invalidateAll: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
{m.chronik_mark_all_read()}
|
||||||
type="submit"
|
</button>
|
||||||
data-testid="chronik-mark-all-read"
|
|
||||||
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.chronik_mark_all_read()}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul role="list" class="flex flex-col gap-2">
|
<ul role="list" class="flex flex-col gap-2">
|
||||||
@@ -109,7 +89,7 @@ function href(n: NotificationItem): string {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent"
|
class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent"
|
||||||
>
|
>
|
||||||
{n.type === 'MENTION' ? '@' : '↩'}
|
{n.type === 'MENTION' ? '@' : '\u21A9'}
|
||||||
</span>
|
</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="font-sans text-sm leading-snug text-ink">
|
<p class="font-sans text-sm leading-snug text-ink">
|
||||||
@@ -120,40 +100,25 @@ function href(n: NotificationItem): string {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<form
|
<button
|
||||||
action="/aktivitaeten?/dismiss-notification"
|
type="button"
|
||||||
method="POST"
|
data-testid="chronik-fuerdich-dismiss"
|
||||||
use:enhance={() => {
|
aria-label={m.chronik_mark_read_aria()}
|
||||||
errorMessage = null;
|
onclick={() => onMarkRead(n)}
|
||||||
optimisticMarkRead(n.id);
|
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||||
return async ({ result, update }) => {
|
|
||||||
if (result.type === 'failure' || result.type === 'error') {
|
|
||||||
errorMessage = m.notification_error_generic();
|
|
||||||
await update({ reset: false, invalidateAll: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="notificationId" value={n.id} />
|
<svg
|
||||||
<button
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
type="submit"
|
class="h-4 w-4"
|
||||||
data-testid="chronik-fuerdich-dismiss"
|
fill="none"
|
||||||
aria-label={m.chronik_mark_read_aria()}
|
viewBox="0 0 24 24"
|
||||||
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<svg
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</svg>
|
||||||
class="h-4 w-4"
|
</button>
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -5,36 +5,7 @@ import { page, userEvent } from 'vitest/browser';
|
|||||||
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||||
|
|
||||||
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
afterEach(cleanup);
|
||||||
|
|
||||||
vi.mock('$app/forms', () => ({
|
|
||||||
enhance(
|
|
||||||
node: HTMLFormElement,
|
|
||||||
submit?: (opts: {
|
|
||||||
formData: FormData;
|
|
||||||
}) => (opts: {
|
|
||||||
result: { type: string; data?: Record<string, unknown> };
|
|
||||||
update: () => Promise<void>;
|
|
||||||
}) => Promise<void>
|
|
||||||
) {
|
|
||||||
const handler = async (e: Event) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const cb = submit?.({ formData: new FormData(node) } as never);
|
|
||||||
if (typeof cb === 'function') {
|
|
||||||
await (
|
|
||||||
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
|
|
||||||
)({ result: mockFormResult, update: async () => {} });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
node.addEventListener('submit', handler);
|
|
||||||
return { destroy: () => node.removeEventListener('submit', handler) };
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
mockFormResult.type = 'success';
|
|
||||||
});
|
|
||||||
|
|
||||||
function notif(partial: Partial<NotificationItem>): NotificationItem {
|
function notif(partial: Partial<NotificationItem>): NotificationItem {
|
||||||
return {
|
return {
|
||||||
@@ -55,8 +26,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('renders inbox-zero state when there are no unread items', async () => {
|
it('renders inbox-zero state when there are no unread items', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [],
|
unread: [],
|
||||||
optimisticMarkRead: vi.fn(),
|
onMarkRead: vi.fn(),
|
||||||
optimisticMarkAllRead: vi.fn()
|
onMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
|
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
|
||||||
expect(zero).not.toBeNull();
|
expect(zero).not.toBeNull();
|
||||||
@@ -66,8 +37,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('links to the archived mentions in the inbox-zero state', async () => {
|
it('links to the archived mentions in the inbox-zero state', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [],
|
unread: [],
|
||||||
optimisticMarkRead: vi.fn(),
|
onMarkRead: vi.fn(),
|
||||||
optimisticMarkAllRead: vi.fn()
|
onMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
|
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
|
||||||
expect(link).not.toBeNull();
|
expect(link).not.toBeNull();
|
||||||
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('renders the count badge with correct total when unread exists', async () => {
|
it('renders the count badge with correct total when unread exists', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' }), notif({ id: 'b' })],
|
unread: [notif({ id: 'a' }), notif({ id: 'b' })],
|
||||||
optimisticMarkRead: vi.fn(),
|
onMarkRead: vi.fn(),
|
||||||
optimisticMarkAllRead: vi.fn()
|
onMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
await expect.element(page.getByText('2 neu')).toBeInTheDocument();
|
await expect.element(page.getByText('2 neu')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -85,8 +56,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('count badge has aria-live=polite when unread exists', async () => {
|
it('count badge has aria-live=polite when unread exists', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' })],
|
unread: [notif({ id: 'a' })],
|
||||||
optimisticMarkRead: vi.fn(),
|
onMarkRead: vi.fn(),
|
||||||
optimisticMarkAllRead: vi.fn()
|
onMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
// Wait for render
|
// Wait for render
|
||||||
await expect.element(page.getByText('1 neu')).toBeInTheDocument();
|
await expect.element(page.getByText('1 neu')).toBeInTheDocument();
|
||||||
@@ -98,8 +69,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('does not render the "Alle gelesen" button when there are no unread items', async () => {
|
it('does not render the "Alle gelesen" button when there are no unread items', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [],
|
unread: [],
|
||||||
optimisticMarkRead: vi.fn(),
|
onMarkRead: vi.fn(),
|
||||||
optimisticMarkAllRead: vi.fn()
|
onMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
|
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
|
||||||
const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
|
const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
|
||||||
@@ -109,38 +80,38 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('renders the "Alle gelesen" button when unread exists', async () => {
|
it('renders the "Alle gelesen" button when unread exists', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' })],
|
unread: [notif({ id: 'a' })],
|
||||||
optimisticMarkRead: vi.fn(),
|
onMarkRead: vi.fn(),
|
||||||
optimisticMarkAllRead: vi.fn()
|
onMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
|
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => {
|
it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
|
||||||
const optimisticMarkAllRead = vi.fn();
|
const onMarkAllRead = vi.fn();
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'a' })],
|
unread: [notif({ id: 'a' })],
|
||||||
optimisticMarkRead: vi.fn(),
|
onMarkRead: vi.fn(),
|
||||||
optimisticMarkAllRead
|
onMarkAllRead
|
||||||
});
|
});
|
||||||
await userEvent.click(page.getByText('Alle gelesen'));
|
await userEvent.click(page.getByText('Alle gelesen'));
|
||||||
expect(optimisticMarkAllRead).toHaveBeenCalledTimes(1);
|
expect(onMarkAllRead).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => {
|
it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => {
|
||||||
const optimisticMarkRead = vi.fn();
|
const onMarkRead = vi.fn();
|
||||||
const n = notif({ id: 'xyz' });
|
const n = notif({ id: 'xyz' });
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [n],
|
unread: [n],
|
||||||
optimisticMarkRead,
|
onMarkRead,
|
||||||
optimisticMarkAllRead: vi.fn()
|
onMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const dismiss = document.querySelector(
|
const dismiss = document.querySelector(
|
||||||
'[data-testid="chronik-fuerdich-dismiss"]'
|
'[data-testid="chronik-fuerdich-dismiss"]'
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
expect(dismiss).not.toBeNull();
|
expect(dismiss).not.toBeNull();
|
||||||
dismiss?.click();
|
dismiss?.click();
|
||||||
expect(optimisticMarkRead).toHaveBeenCalledTimes(1);
|
expect(onMarkRead).toHaveBeenCalledTimes(1);
|
||||||
expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz');
|
expect(onMarkRead.mock.calls[0][0]).toEqual(n);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
|
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
|
||||||
@@ -153,8 +124,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
annotationId: 'annot-9'
|
annotationId: 'annot-9'
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
optimisticMarkRead: vi.fn(),
|
onMarkRead: vi.fn(),
|
||||||
optimisticMarkAllRead: vi.fn()
|
onMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const link = document.querySelector(
|
const link = document.querySelector(
|
||||||
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
|
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
|
||||||
@@ -165,8 +136,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
|
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
unread: [notif({ id: 'x' })],
|
unread: [notif({ id: 'x' })],
|
||||||
optimisticMarkRead: vi.fn(),
|
onMarkRead: vi.fn(),
|
||||||
optimisticMarkAllRead: vi.fn()
|
onMarkAllRead: vi.fn()
|
||||||
});
|
});
|
||||||
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
|
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
|
||||||
expect(dismiss).not.toBeNull();
|
expect(dismiss).not.toBeNull();
|
||||||
@@ -174,22 +145,4 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
// Prevents the senior-audience tap-drag bug flagged by Leonie.
|
// Prevents the senior-audience tap-drag bug flagged by Leonie.
|
||||||
expect(dismiss?.closest('a')).toBeNull();
|
expect(dismiss?.closest('a')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
|
|
||||||
mockFormResult.type = 'failure';
|
|
||||||
render(ChronikFuerDichBox, {
|
|
||||||
unread: [notif({ id: 'err-1' })],
|
|
||||||
optimisticMarkRead: vi.fn(),
|
|
||||||
optimisticMarkAllRead: vi.fn()
|
|
||||||
});
|
|
||||||
const dismiss = document.querySelector(
|
|
||||||
'[data-testid="chronik-fuerdich-dismiss"]'
|
|
||||||
) as HTMLButtonElement | null;
|
|
||||||
expect(dismiss).not.toBeNull();
|
|
||||||
dismiss?.click();
|
|
||||||
// Allow microtask queue to flush
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
const alert = document.querySelector('[role="alert"]');
|
|
||||||
expect(alert).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,36 +4,7 @@ import { page } from 'vitest/browser';
|
|||||||
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
||||||
import type { NotificationItem } from '$lib/notification/notifications';
|
import type { NotificationItem } from '$lib/notification/notifications';
|
||||||
|
|
||||||
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
afterEach(cleanup);
|
||||||
|
|
||||||
vi.mock('$app/forms', () => ({
|
|
||||||
enhance(
|
|
||||||
node: HTMLFormElement,
|
|
||||||
submit?: (opts: {
|
|
||||||
formData: FormData;
|
|
||||||
}) => (opts: {
|
|
||||||
result: { type: string; data?: Record<string, unknown> };
|
|
||||||
update: () => Promise<void>;
|
|
||||||
}) => Promise<void>
|
|
||||||
) {
|
|
||||||
const handler = async (e: Event) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const cb = submit?.({ formData: new FormData(node) } as never);
|
|
||||||
if (typeof cb === 'function') {
|
|
||||||
await (
|
|
||||||
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
|
|
||||||
)({ result: mockFormResult, update: async () => {} });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
node.addEventListener('submit', handler);
|
|
||||||
return { destroy: () => node.removeEventListener('submit', handler) };
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
mockFormResult.type = 'success';
|
|
||||||
});
|
|
||||||
|
|
||||||
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||||
id: 'n-1',
|
id: 'n-1',
|
||||||
@@ -51,7 +22,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
|
|||||||
describe('ChronikFuerDichBox', () => {
|
describe('ChronikFuerDichBox', () => {
|
||||||
it('renders the inbox-zero state when there are no unread', async () => {
|
it('renders the inbox-zero state when there are no unread', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} }
|
props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
|
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
|
||||||
@@ -63,8 +34,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
|
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {}
|
onMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
|
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {}
|
onMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,8 +62,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ actorName: 'Bertha' })],
|
unread: [mention({ actorName: 'Bertha' })],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {}
|
onMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
|
unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {}
|
onMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,11 +86,11 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
.toBeVisible();
|
.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => {
|
it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
|
||||||
const optimisticMarkRead = vi.fn();
|
const onMarkRead = vi.fn();
|
||||||
const item = mention({ id: 'n-7' });
|
const item = mention({ id: 'n-7' });
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} }
|
props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
|
||||||
});
|
});
|
||||||
|
|
||||||
const dismiss = document.querySelector(
|
const dismiss = document.querySelector(
|
||||||
@@ -127,55 +98,35 @@ describe('ChronikFuerDichBox', () => {
|
|||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
dismiss.click();
|
dismiss.click();
|
||||||
|
|
||||||
expect(optimisticMarkRead).toHaveBeenCalledWith('n-7');
|
expect(onMarkRead).toHaveBeenCalledWith(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => {
|
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
|
||||||
const optimisticMarkAllRead = vi.fn();
|
const onMarkAllRead = vi.fn();
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention()],
|
unread: [mention()],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead
|
onMarkAllRead
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
|
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
|
||||||
btn.click();
|
btn.click();
|
||||||
|
|
||||||
expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
|
expect(onMarkAllRead).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds a deep-link href to the comment for each notification', async () => {
|
it('builds a deep-link href to the comment for each notification', async () => {
|
||||||
render(ChronikFuerDichBox, {
|
render(ChronikFuerDichBox, {
|
||||||
props: {
|
props: {
|
||||||
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
|
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {}
|
onMarkAllRead: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
|
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
|
||||||
expect(link.getAttribute('href')).toContain('doc-x');
|
expect(link.getAttribute('href')).toContain('doc-x');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
|
|
||||||
mockFormResult.type = 'failure';
|
|
||||||
render(ChronikFuerDichBox, {
|
|
||||||
props: {
|
|
||||||
unread: [mention({ id: 'err-1' })],
|
|
||||||
optimisticMarkRead: () => {},
|
|
||||||
optimisticMarkAllRead: () => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const dismiss = document.querySelector(
|
|
||||||
'[data-testid="chronik-fuerdich-dismiss"]'
|
|
||||||
) as HTMLElement;
|
|
||||||
dismiss.click();
|
|
||||||
// Allow microtask queue to flush
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
const alert = document.querySelector('[role="alert"]');
|
|
||||||
expect(alert).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import PdfViewer from '$lib/document/viewer/PdfViewer.svelte';
|
|||||||
import { bulkTitleFromFilename } from '$lib/document/filename';
|
import { bulkTitleFromFilename } from '$lib/document/filename';
|
||||||
import type { Tag } from '$lib/tag/TagInput.svelte';
|
import type { Tag } from '$lib/tag/TagInput.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { withCsrf } from '$lib/shared/cookies';
|
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
@@ -184,10 +183,7 @@ async function saveUpload() {
|
|||||||
// FormData with per-chunk progress. Session cookie is sent automatically
|
// FormData with per-chunk progress. Session cookie is sent automatically
|
||||||
// by the browser for same-origin requests.
|
// by the browser for same-origin requests.
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
||||||
'/api/documents/quick-upload',
|
|
||||||
withCsrf({ method: 'POST', body: formData })
|
|
||||||
);
|
|
||||||
const body = await res.json().catch(() => ({ errors: [] }));
|
const body = await res.json().catch(() => ({ errors: [] }));
|
||||||
const errorFilenames = new Set<string>(
|
const errorFilenames = new Set<string>(
|
||||||
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page, userEvent } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { goto } from '$app/navigation';
|
|||||||
import BulkSelectionBar from './BulkSelectionBar.svelte';
|
import BulkSelectionBar from './BulkSelectionBar.svelte';
|
||||||
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import DocumentRow from './DocumentRow.svelte';
|
|||||||
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { default: DocumentRow } = await import('./DocumentRow.svelte');
|
const { default: DocumentRow } = await import('./DocumentRow.svelte');
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyStat
|
|||||||
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
|
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
|
||||||
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
|
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
|
||||||
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
|
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
|
||||||
import { withCsrf } from '$lib/shared/cookies';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -50,7 +49,6 @@ let activeBlockId: string | null = $state(null);
|
|||||||
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
||||||
let listEl: HTMLElement | null = $state(null);
|
let listEl: HTMLElement | null = $state(null);
|
||||||
let markingAllReviewed = $state(false);
|
let markingAllReviewed = $state(false);
|
||||||
let markAllError = $state<string | null>(null);
|
|
||||||
|
|
||||||
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||||
const hasBlocks = $derived(blocks.length > 0);
|
const hasBlocks = $derived(blocks.length > 0);
|
||||||
@@ -69,11 +67,8 @@ $effect(() => {
|
|||||||
async function handleMarkAllReviewed() {
|
async function handleMarkAllReviewed() {
|
||||||
if (!onMarkAllReviewed) return;
|
if (!onMarkAllReviewed) return;
|
||||||
markingAllReviewed = true;
|
markingAllReviewed = true;
|
||||||
markAllError = null;
|
|
||||||
try {
|
try {
|
||||||
await onMarkAllReviewed();
|
await onMarkAllReviewed();
|
||||||
} catch {
|
|
||||||
markAllError = m.transcription_mark_all_reviewed_error();
|
|
||||||
} finally {
|
} finally {
|
||||||
markingAllReviewed = false;
|
markingAllReviewed = false;
|
||||||
}
|
}
|
||||||
@@ -114,14 +109,11 @@ function handleDelete(blockId: string) {
|
|||||||
|
|
||||||
async function reorder(newOrder: string[]) {
|
async function reorder(newOrder: string[]) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
|
||||||
`/api/documents/${documentId}/transcription-blocks/reorder`,
|
method: 'PUT',
|
||||||
withCsrf({
|
headers: { 'Content-Type': 'application/json' },
|
||||||
method: 'PUT',
|
body: JSON.stringify({ blockIds: newOrder })
|
||||||
headers: { 'Content-Type': 'application/json' },
|
});
|
||||||
body: JSON.stringify({ blockIds: newOrder })
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
for (const b of updated) {
|
for (const b of updated) {
|
||||||
@@ -177,7 +169,7 @@ async function handleLabelToggle(label: string) {
|
|||||||
<button
|
<button
|
||||||
onclick={handleMarkAllReviewed}
|
onclick={handleMarkAllReviewed}
|
||||||
disabled={allReviewed || markingAllReviewed}
|
disabled={allReviewed || markingAllReviewed}
|
||||||
title={allReviewed ? m.transcription_mark_all_reviewed_disabled() : undefined}
|
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
|
||||||
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
|
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{#if markingAllReviewed}
|
{#if markingAllReviewed}
|
||||||
@@ -215,7 +207,7 @@ async function handleLabelToggle(label: string) {
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
{m.transcription_mark_all_reviewed()}
|
Alle als fertig markieren
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -225,31 +217,6 @@ async function handleLabelToggle(label: string) {
|
|||||||
style="width: {reviewProgress}%"
|
style="width: {reviewProgress}%"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{#if markAllError}
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
class="mt-1.5 flex items-center gap-2 rounded-sm border border-red-200 bg-red-50 px-3 py-2 font-sans text-sm text-red-700"
|
|
||||||
>
|
|
||||||
<span class="flex-1">{markAllError}</span>
|
|
||||||
<button
|
|
||||||
onclick={() => (markAllError = null)}
|
|
||||||
aria-label={m.comp_dismiss()}
|
|
||||||
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-red-600 hover:text-red-700 focus-visible:ring-2 focus-visible:ring-red-500"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page, userEvent } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import TranscriptionEditView from './TranscriptionEditView.svelte';
|
import TranscriptionEditView from './TranscriptionEditView.svelte';
|
||||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -313,14 +312,14 @@ describe('TranscriptionEditView — mark all reviewed', () => {
|
|||||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||||
});
|
});
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
|
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
|
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
|
||||||
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
|
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
|
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -330,7 +329,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
|
|||||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||||
});
|
});
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
|
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||||
.toBeDisabled();
|
.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,7 +343,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
|
|||||||
// userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick
|
// userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick
|
||||||
// handlers when a TipTap editor is mounted in the same component tree.
|
// handlers when a TipTap editor is mounted in the same component tree.
|
||||||
const btn = (await page
|
const btn = (await page
|
||||||
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
|
.getByRole('button', { name: /Alle als fertig markieren/ })
|
||||||
.element()) as HTMLButtonElement;
|
.element()) as HTMLButtonElement;
|
||||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
|
||||||
@@ -362,83 +361,12 @@ describe('TranscriptionEditView — mark all reviewed', () => {
|
|||||||
|
|
||||||
// Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick
|
// Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick
|
||||||
const btnEl = (await page
|
const btnEl = (await page
|
||||||
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
|
.getByRole('button', { name: /Alle als fertig markieren/ })
|
||||||
.element()) as HTMLButtonElement;
|
.element()) as HTMLButtonElement;
|
||||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
|
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||||
.toBeDisabled();
|
.toBeDisabled();
|
||||||
resolveMarkAll();
|
resolveMarkAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error message when onMarkAllReviewed callback rejects', async () => {
|
|
||||||
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
|
|
||||||
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
|
|
||||||
|
|
||||||
const btnEl = (await page
|
|
||||||
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
|
|
||||||
.element()) as HTMLButtonElement;
|
|
||||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
||||||
|
|
||||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('alert'))
|
|
||||||
.toHaveTextContent(m.transcription_mark_all_reviewed_error());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears error when dismiss button is clicked', async () => {
|
|
||||||
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
|
|
||||||
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
|
|
||||||
|
|
||||||
const btnEl = (await page
|
|
||||||
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
|
|
||||||
.element()) as HTMLButtonElement;
|
|
||||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
||||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
|
||||||
|
|
||||||
const dismissEl = (await page
|
|
||||||
.getByRole('button', { name: m.comp_dismiss() })
|
|
||||||
.element()) as HTMLButtonElement;
|
|
||||||
dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
||||||
|
|
||||||
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears error on next successful markAllReviewed call', async () => {
|
|
||||||
const onMarkAllReviewed = vi
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValueOnce(new Error('INTERNAL_ERROR'))
|
|
||||||
.mockResolvedValue(undefined);
|
|
||||||
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
|
|
||||||
|
|
||||||
const btnEl = (await page
|
|
||||||
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
|
|
||||||
.element()) as HTMLButtonElement;
|
|
||||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
||||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
|
||||||
// Wait for the button to be re-enabled before the second click — ensures the first
|
|
||||||
// async rejection has fully settled and Svelte has flushed state changes
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
|
|
||||||
.not.toBeDisabled();
|
|
||||||
|
|
||||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
||||||
|
|
||||||
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('re-enables button after markAllReviewed failure', async () => {
|
|
||||||
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
|
|
||||||
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
|
|
||||||
|
|
||||||
const btnEl = (await page
|
|
||||||
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
|
|
||||||
.element()) as HTMLButtonElement;
|
|
||||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
||||||
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
|
|
||||||
.not.toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import type { PersonMention } from '$lib/shared/types';
|
import type { PersonMention } from '$lib/shared/types';
|
||||||
import { withCsrf } from '$lib/shared/cookies';
|
|
||||||
|
|
||||||
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||||
|
|
||||||
@@ -117,15 +116,12 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
|||||||
for (const [blockId, text] of pendingTexts) {
|
for (const [blockId, text] of pendingTexts) {
|
||||||
const mentions = pendingMentions.get(blockId) ?? [];
|
const mentions = pendingMentions.get(blockId) ?? [];
|
||||||
clearDebounce(blockId);
|
clearDebounce(blockId);
|
||||||
void fetch(
|
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
|
||||||
`/api/documents/${documentId}/transcription-blocks/${blockId}`,
|
method: 'PUT',
|
||||||
withCsrf({
|
headers: { 'Content-Type': 'application/json' },
|
||||||
method: 'PUT',
|
body: JSON.stringify({ text, mentionedPersons: mentions }),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
keepalive: true
|
||||||
body: JSON.stringify({ text, mentionedPersons: mentions }),
|
});
|
||||||
keepalive: true
|
|
||||||
})
|
|
||||||
);
|
|
||||||
pendingTexts.delete(blockId);
|
pendingTexts.delete(blockId);
|
||||||
pendingMentions.delete(blockId);
|
pendingMentions.delete(blockId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,15 +259,12 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
|
|||||||
expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true);
|
expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws and leaves blocks unchanged when PUT returns non-OK', async () => {
|
it('is a no-op when PUT returns non-OK', async () => {
|
||||||
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const u = url.toString();
|
const u = url.toString();
|
||||||
const method = init?.method ?? 'GET';
|
const method = init?.method ?? 'GET';
|
||||||
if (u.includes('/review-all') && method === 'PUT') {
|
if (u.includes('/review-all') && method === 'PUT') {
|
||||||
return new Response(JSON.stringify({ code: 'INTERNAL_ERROR' }), {
|
return new Response('', { status: 500 });
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
|
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -277,26 +274,7 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
|
|||||||
|
|
||||||
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
|
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
|
||||||
await ctrl.load();
|
await ctrl.load();
|
||||||
await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR');
|
await ctrl.markAllReviewed();
|
||||||
expect(ctrl.blocks[0].reviewed).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws INTERNAL_ERROR when PUT returns non-JSON body (e.g. nginx 502)', async () => {
|
|
||||||
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const u = url.toString();
|
|
||||||
const method = init?.method ?? 'GET';
|
|
||||||
if (u.includes('/review-all') && method === 'PUT') {
|
|
||||||
return new Response('Bad Gateway', { status: 502 });
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
|
|
||||||
await ctrl.load();
|
|
||||||
await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR');
|
|
||||||
expect(ctrl.blocks[0].reviewed).toBe(false);
|
expect(ctrl.blocks[0].reviewed).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
lastEditedAt's $derived are scope-local to one computation; they're never
|
lastEditedAt's $derived are scope-local to one computation; they're never
|
||||||
stored on $state. */
|
stored on $state. */
|
||||||
import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types';
|
import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types';
|
||||||
import { makeCsrfFetch } from '$lib/shared/cookies';
|
|
||||||
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
|
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
|
||||||
import { BlockConflictResolvedError } from './blockConflictMerge';
|
import { BlockConflictResolvedError } from './blockConflictMerge';
|
||||||
|
|
||||||
@@ -42,7 +41,7 @@ export function createTranscriptionBlocks(
|
|||||||
options: TranscriptionBlocksOptions
|
options: TranscriptionBlocksOptions
|
||||||
): TranscriptionBlocksController {
|
): TranscriptionBlocksController {
|
||||||
const { documentId } = options;
|
const { documentId } = options;
|
||||||
const fetchImpl = makeCsrfFetch(options.fetchImpl ?? fetch);
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
|
||||||
let blocks = $state<TranscriptionBlockData[]>([]);
|
let blocks = $state<TranscriptionBlockData[]>([]);
|
||||||
let annotationReloadKey = $state(0);
|
let annotationReloadKey = $state(0);
|
||||||
@@ -120,11 +119,7 @@ export function createTranscriptionBlocks(
|
|||||||
const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, {
|
const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, {
|
||||||
method: 'PUT'
|
method: 'PUT'
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) return;
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
// Never render body.message — route through getErrorMessage() to prevent leaking backend internals
|
|
||||||
throw new Error((body as { code?: string })?.code ?? 'INTERNAL_ERROR');
|
|
||||||
}
|
|
||||||
const updated = (await res.json()) as { id: string; reviewed: boolean }[];
|
const updated = (await res.json()) as { id: string; reviewed: boolean }[];
|
||||||
for (const b of updated) {
|
for (const b of updated) {
|
||||||
const existing = blocks.find((x) => x.id === b.id);
|
const existing = blocks.find((x) => x.id === b.id);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||||
import { notificationStore } from '$lib/notification/notifications.svelte';
|
import { notificationStore } from '$lib/notification/notifications.svelte';
|
||||||
|
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
||||||
import NotificationDropdown from './NotificationDropdown.svelte';
|
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||||
|
|
||||||
let open = $state(false);
|
let open = $state(false);
|
||||||
@@ -28,6 +30,17 @@ function closeDropdown() {
|
|||||||
bellButtonEl?.focus();
|
bellButtonEl?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
|
||||||
|
await stream.markRead(notification);
|
||||||
|
const url = buildCommentHref(
|
||||||
|
notification.documentId,
|
||||||
|
notification.referenceId,
|
||||||
|
notification.annotationId
|
||||||
|
);
|
||||||
|
closeDropdown();
|
||||||
|
goto(url);
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape' && open) {
|
if (event.key === 'Escape' && open) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -100,8 +113,8 @@ onDestroy(() => {
|
|||||||
{#if open}
|
{#if open}
|
||||||
<NotificationDropdown
|
<NotificationDropdown
|
||||||
notifications={stream.notifications}
|
notifications={stream.notifications}
|
||||||
optimisticMarkRead={stream.optimisticMarkRead}
|
onMarkRead={handleMarkRead}
|
||||||
optimisticMarkAllRead={stream.optimisticMarkAllRead}
|
onMarkAllRead={stream.markAllRead}
|
||||||
onClose={closeDropdown}
|
onClose={closeDropdown}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,18 +3,10 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import type { NotificationItem } from '$lib/notification/notifications';
|
import type { NotificationItem } from '$lib/notification/notifications';
|
||||||
import NotificationBell from './NotificationBell.svelte';
|
import NotificationBell from './NotificationBell.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
const gotoMock = vi.hoisted(() => vi.fn());
|
||||||
vi.mock('$app/forms', () => ({
|
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
|
||||||
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
|
|
||||||
const handler = (e: Event) => {
|
|
||||||
e.preventDefault();
|
|
||||||
submit?.({ formData: new FormData(node) } as never);
|
|
||||||
};
|
|
||||||
node.addEventListener('submit', handler);
|
|
||||||
return { destroy: () => node.removeEventListener('submit', handler) };
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
||||||
|
|
||||||
vi.mock('$lib/notification/notifications.svelte', () => ({
|
vi.mock('$lib/notification/notifications.svelte', () => ({
|
||||||
@@ -25,17 +17,18 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
|
|||||||
get unreadCount() {
|
get unreadCount() {
|
||||||
return mockNotificationList.value.length;
|
return mockNotificationList.value.length;
|
||||||
},
|
},
|
||||||
optimisticMarkRead: vi.fn(),
|
markRead: mockMarkRead,
|
||||||
optimisticMarkAllRead: vi.fn(),
|
|
||||||
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
||||||
init: vi.fn(),
|
init: vi.fn(),
|
||||||
destroy: vi.fn()
|
destroy: vi.fn(),
|
||||||
|
markAllRead: vi.fn()
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
gotoMock.mockClear();
|
||||||
|
mockMarkRead.mockClear();
|
||||||
mockNotificationList.value = [];
|
mockNotificationList.value = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,6 +45,16 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
|
|||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function openDropdownAndClickFirstNotification() {
|
||||||
|
const bellButton = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
|
||||||
|
bellButton.click();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
const notifButton = document.querySelector<HTMLButtonElement>('[role="list"] button')!;
|
||||||
|
notifButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
describe('NotificationBell — cursor and tooltip', () => {
|
describe('NotificationBell — cursor and tooltip', () => {
|
||||||
it('bell button has cursor-pointer class', async () => {
|
it('bell button has cursor-pointer class', async () => {
|
||||||
render(NotificationBell);
|
render(NotificationBell);
|
||||||
@@ -79,3 +82,29 @@ describe('NotificationBell — cursor and tooltip', () => {
|
|||||||
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('NotificationBell', () => {
|
||||||
|
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
|
||||||
|
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
|
||||||
|
render(NotificationBell);
|
||||||
|
|
||||||
|
await openDropdownAndClickFirstNotification();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(gotoMock).toHaveBeenCalledWith(
|
||||||
|
'/documents/doc-1?commentId=ref-1&annotationId=annot-1'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => {
|
||||||
|
mockNotificationList.value = [makeNotification({ annotationId: null })];
|
||||||
|
render(NotificationBell);
|
||||||
|
|
||||||
|
await openDropdownAndClickFirstNotification();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { relativeTime } from '$lib/shared/utils/time';
|
import { relativeTime } from '$lib/shared/utils/time';
|
||||||
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
|
||||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
notifications: NotificationItem[];
|
notifications: NotificationItem[];
|
||||||
optimisticMarkRead: (id: string) => void;
|
onMarkRead: (notification: NotificationItem) => void;
|
||||||
optimisticMarkAllRead: () => void;
|
onMarkAllRead: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props();
|
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
|
||||||
|
|
||||||
let errorMessage = $state<string | null>(null);
|
|
||||||
|
|
||||||
function handleViewAll() {
|
function handleViewAll() {
|
||||||
onClose(); // close first — avoids stale dropdown during navigation transition
|
onClose(); // close first — avoids stale dropdown during navigation transition
|
||||||
@@ -35,35 +31,16 @@ function handleViewAll() {
|
|||||||
{m.notification_bell_label()}
|
{m.notification_bell_label()}
|
||||||
</span>
|
</span>
|
||||||
{#if notifications.length > 0}
|
{#if notifications.length > 0}
|
||||||
<form
|
<button
|
||||||
action="/aktivitaeten?/mark-all-read"
|
type="button"
|
||||||
method="POST"
|
onclick={onMarkAllRead}
|
||||||
use:enhance={() => {
|
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||||
errorMessage = null;
|
|
||||||
optimisticMarkAllRead();
|
|
||||||
return async ({ result, update }) => {
|
|
||||||
if (result.type === 'failure' || result.type === 'error') {
|
|
||||||
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
|
|
||||||
await update({ reset: false, invalidateAll: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
{m.notification_mark_all_read()}
|
||||||
type="submit"
|
</button>
|
||||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
|
||||||
>
|
|
||||||
{m.notification_mark_all_read()}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error banner (shown when a dismiss or mark-all action fails) -->
|
|
||||||
{#if errorMessage}
|
|
||||||
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Notification list -->
|
<!-- Notification list -->
|
||||||
{#if notifications.length === 0}
|
{#if notifications.length === 0}
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
@@ -89,93 +66,67 @@ function handleViewAll() {
|
|||||||
<ul role="list" class="max-h-[24rem] overflow-y-auto">
|
<ul role="list" class="max-h-[24rem] overflow-y-auto">
|
||||||
{#each notifications as notification (notification.id)}
|
{#each notifications as notification (notification.id)}
|
||||||
<li>
|
<li>
|
||||||
<form
|
<button
|
||||||
action="/aktivitaeten?/dismiss-notification"
|
type="button"
|
||||||
method="POST"
|
onclick={() => onMarkRead(notification)}
|
||||||
class="contents"
|
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
|
||||||
use:enhance={() => {
|
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||||
errorMessage = null;
|
|
||||||
optimisticMarkRead(notification.id);
|
|
||||||
return async ({ result, update }) => {
|
|
||||||
if (result.type === 'failure' || result.type === 'error') {
|
|
||||||
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
|
|
||||||
await update({ reset: false, invalidateAll: false });
|
|
||||||
} else {
|
|
||||||
// Navigate away — no need to update the store since we're leaving the page
|
|
||||||
onClose();
|
|
||||||
goto(
|
|
||||||
buildCommentHref(
|
|
||||||
notification.documentId,
|
|
||||||
notification.referenceId,
|
|
||||||
notification.annotationId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input type="hidden" name="notificationId" value={notification.id} />
|
<!-- Type icon -->
|
||||||
<button
|
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||||
type="submit"
|
{#if notification.type === 'REPLY'}
|
||||||
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3.5 text-left last:border-b-0 hover:bg-canvas
|
<!-- Reply icon -->
|
||||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
<svg
|
||||||
>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<!-- Type icon -->
|
class="h-4 w-4"
|
||||||
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
fill="none"
|
||||||
{#if notification.type === 'REPLY'}
|
viewBox="0 0 24 24"
|
||||||
<!-- Reply icon -->
|
stroke="currentColor"
|
||||||
<svg
|
stroke-width="2"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
>
|
||||||
class="h-4 w-4"
|
<path
|
||||||
fill="none"
|
stroke-linecap="round"
|
||||||
viewBox="0 0 24 24"
|
stroke-linejoin="round"
|
||||||
stroke="currentColor"
|
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||||
stroke-width="2"
|
/>
|
||||||
>
|
</svg>
|
||||||
<path
|
{:else}
|
||||||
stroke-linecap="round"
|
<!-- Mention icon -->
|
||||||
stroke-linejoin="round"
|
<svg
|
||||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
/>
|
class="h-4 w-4"
|
||||||
</svg>
|
fill="none"
|
||||||
{:else}
|
viewBox="0 0 24 24"
|
||||||
<!-- Mention icon -->
|
stroke="currentColor"
|
||||||
<svg
|
stroke-width="2"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
>
|
||||||
class="h-4 w-4"
|
<path
|
||||||
fill="none"
|
stroke-linecap="round"
|
||||||
viewBox="0 0 24 24"
|
stroke-linejoin="round"
|
||||||
stroke="currentColor"
|
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
||||||
stroke-width="2"
|
/>
|
||||||
>
|
</svg>
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Text + time -->
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p class="text-sm leading-snug text-ink">
|
|
||||||
{notification.type === 'REPLY'
|
|
||||||
? m.notification_type_reply({ actor: notification.actorName })
|
|
||||||
: m.notification_type_mention({ actor: notification.actorName })}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Unread dot -->
|
|
||||||
{#if !notification.read}
|
|
||||||
<span
|
|
||||||
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
|
||||||
aria-label={m.notification_unread()}
|
|
||||||
></span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</span>
|
||||||
</form>
|
|
||||||
|
<!-- Text + time -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm leading-snug text-ink">
|
||||||
|
{notification.type === 'REPLY'
|
||||||
|
? m.notification_type_reply({ actor: notification.actorName })
|
||||||
|
: m.notification_type_mention({ actor: notification.actorName })}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unread dot -->
|
||||||
|
{#if !notification.read}
|
||||||
|
<span
|
||||||
|
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||||
|
aria-label={m.notification_unread()}
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -4,40 +4,11 @@ import { page } from 'vitest/browser';
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import NotificationDropdown from './NotificationDropdown.svelte';
|
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
// Configurable result for the enhance mock — tests that need failure set
|
|
||||||
// mockFormResult.type = 'failure' before clicking.
|
|
||||||
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
|
||||||
|
|
||||||
// Invoke the SubmitFunction and always call the returned result callback with
|
|
||||||
// mockFormResult so tests can exercise both success and failure branches.
|
|
||||||
vi.mock('$app/forms', () => ({
|
|
||||||
enhance(
|
|
||||||
node: HTMLFormElement,
|
|
||||||
submit?: (opts: {
|
|
||||||
formData: FormData;
|
|
||||||
}) => (opts: {
|
|
||||||
result: { type: string; data?: Record<string, unknown> };
|
|
||||||
update: () => Promise<void>;
|
|
||||||
}) => Promise<void>
|
|
||||||
) {
|
|
||||||
const handler = async (e: Event) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const cb = submit?.({ formData: new FormData(node) } as never);
|
|
||||||
if (typeof cb === 'function') {
|
|
||||||
await cb({ result: mockFormResult, update: async () => {} } as never);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
node.addEventListener('submit', handler);
|
|
||||||
return { destroy: () => node.removeEventListener('submit', handler) };
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockFormResult.type = 'success'; // reset to default after each test
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
|
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
|
||||||
@@ -58,8 +29,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -71,8 +42,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -84,8 +55,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -99,8 +70,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification()],
|
notifications: [makeNotification()],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -112,8 +83,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
|
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -127,8 +98,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
|
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -145,8 +116,8 @@ describe('NotificationDropdown', () => {
|
|||||||
makeNotification({ id: 'n1', read: false }),
|
makeNotification({ id: 'n1', read: false }),
|
||||||
makeNotification({ id: 'n2', read: true })
|
makeNotification({ id: 'n2', read: true })
|
||||||
],
|
],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -155,100 +126,37 @@ describe('NotificationDropdown', () => {
|
|||||||
expect(unreadDots.length).toBe(1);
|
expect(unreadDots.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('each notification row is wrapped in a form posting to the dismiss action', async () => {
|
it('calls onMarkRead with the notification when an item is clicked', async () => {
|
||||||
render(NotificationDropdown, {
|
const onMarkRead = vi.fn();
|
||||||
props: {
|
|
||||||
notifications: [makeNotification({ id: 'n42' })],
|
|
||||||
optimisticMarkRead: () => {},
|
|
||||||
optimisticMarkAllRead: () => {},
|
|
||||||
onClose: () => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = document.querySelector('form[action="/aktivitaeten?/dismiss-notification"]');
|
|
||||||
expect(form).not.toBeNull();
|
|
||||||
expect(form?.getAttribute('method')).toBe('POST');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the dismiss form has a hidden notificationId input with the notification id', async () => {
|
|
||||||
render(NotificationDropdown, {
|
|
||||||
props: {
|
|
||||||
notifications: [makeNotification({ id: 'n42' })],
|
|
||||||
optimisticMarkRead: () => {},
|
|
||||||
optimisticMarkAllRead: () => {},
|
|
||||||
onClose: () => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const input = document.querySelector<HTMLInputElement>(
|
|
||||||
'form[action="/aktivitaeten?/dismiss-notification"] input[name="notificationId"]'
|
|
||||||
);
|
|
||||||
expect(input?.value).toBe('n42');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls optimisticMarkRead with the notification id when a row is submitted', async () => {
|
|
||||||
const optimisticMarkRead = vi.fn();
|
|
||||||
const n = makeNotification({ id: 'n42', actorName: 'Anna' });
|
const n = makeNotification({ id: 'n42', actorName: 'Anna' });
|
||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [n],
|
notifications: [n],
|
||||||
optimisticMarkRead,
|
onMarkRead,
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
|
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
|
||||||
|
|
||||||
expect(optimisticMarkRead).toHaveBeenCalledWith('n42');
|
expect(onMarkRead).toHaveBeenCalledWith(n);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the mark-all-read control is a form posting to the mark-all-read action', async () => {
|
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
|
||||||
|
const onMarkAllRead = vi.fn();
|
||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification()],
|
notifications: [makeNotification()],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead,
|
||||||
onClose: () => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = document.querySelector('form[action="/aktivitaeten?/mark-all-read"]');
|
|
||||||
expect(form).not.toBeNull();
|
|
||||||
expect(form?.getAttribute('method')).toBe('POST');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls optimisticMarkAllRead when the mark-all-read button is submitted', async () => {
|
|
||||||
const optimisticMarkAllRead = vi.fn();
|
|
||||||
render(NotificationDropdown, {
|
|
||||||
props: {
|
|
||||||
notifications: [makeNotification()],
|
|
||||||
optimisticMarkRead: () => {},
|
|
||||||
optimisticMarkAllRead,
|
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: /alle gelesen/i }).click();
|
await page.getByRole('button', { name: /alle gelesen/i }).click();
|
||||||
|
|
||||||
expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
|
expect(onMarkAllRead).toHaveBeenCalledOnce();
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a role=alert error banner when mark-all-read returns a failure', async () => {
|
|
||||||
mockFormResult.type = 'failure';
|
|
||||||
render(NotificationDropdown, {
|
|
||||||
props: {
|
|
||||||
notifications: [makeNotification()],
|
|
||||||
optimisticMarkRead: () => {},
|
|
||||||
optimisticMarkAllRead: () => {},
|
|
||||||
onClose: () => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /alle gelesen/i }).click();
|
|
||||||
|
|
||||||
const alert = document.querySelector('[role="alert"]');
|
|
||||||
expect(alert).not.toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onClose when the view-all button is clicked', async () => {
|
it('calls onClose when the view-all button is clicked', async () => {
|
||||||
@@ -256,8 +164,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose
|
onClose
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -271,8 +179,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -285,15 +193,12 @@ describe('NotificationDropdown', () => {
|
|||||||
it('calls onClose before navigating to /aktivitaeten', async () => {
|
it('calls onClose before navigating to /aktivitaeten', async () => {
|
||||||
const callOrder: string[] = [];
|
const callOrder: string[] = [];
|
||||||
const onClose = vi.fn(() => callOrder.push('close'));
|
const onClose = vi.fn(() => callOrder.push('close'));
|
||||||
vi.mocked(goto).mockImplementation(() => {
|
vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
|
||||||
callOrder.push('goto');
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [],
|
notifications: [],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose
|
onClose
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -307,8 +212,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
|
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -320,8 +225,8 @@ describe('NotificationDropdown', () => {
|
|||||||
render(NotificationDropdown, {
|
render(NotificationDropdown, {
|
||||||
props: {
|
props: {
|
||||||
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
|
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -337,78 +242,14 @@ describe('NotificationDropdown', () => {
|
|||||||
makeNotification({ id: 'n1', actorName: 'First' }),
|
makeNotification({ id: 'n1', actorName: 'First' }),
|
||||||
makeNotification({ id: 'n2', actorName: 'Second' })
|
makeNotification({ id: 'n2', actorName: 'Second' })
|
||||||
],
|
],
|
||||||
optimisticMarkRead: () => {},
|
onMarkRead: () => {},
|
||||||
optimisticMarkAllRead: () => {},
|
onMarkAllRead: () => {},
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]');
|
const items = document.querySelectorAll('button[type="button"]');
|
||||||
expect(forms.length).toBe(2);
|
// At least 2 items + mark-all button
|
||||||
});
|
expect(items.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
it('calls onClose and goto with the deep-link URL after a successful dismiss', async () => {
|
|
||||||
const onClose = vi.fn();
|
|
||||||
const n = makeNotification({
|
|
||||||
id: 'n42',
|
|
||||||
documentId: 'd1',
|
|
||||||
referenceId: 'c1',
|
|
||||||
annotationId: null,
|
|
||||||
actorName: 'Anna'
|
|
||||||
});
|
|
||||||
render(NotificationDropdown, {
|
|
||||||
props: {
|
|
||||||
notifications: [n],
|
|
||||||
optimisticMarkRead: () => {},
|
|
||||||
optimisticMarkAllRead: () => {},
|
|
||||||
onClose
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
|
|
||||||
|
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
|
||||||
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT call onClose or goto when the dismiss action returns a failure', async () => {
|
|
||||||
mockFormResult.type = 'failure';
|
|
||||||
const onClose = vi.fn();
|
|
||||||
const n = makeNotification({ id: 'n99', actorName: 'Bob' });
|
|
||||||
render(NotificationDropdown, {
|
|
||||||
props: {
|
|
||||||
notifications: [n],
|
|
||||||
optimisticMarkRead: () => {},
|
|
||||||
optimisticMarkAllRead: () => {},
|
|
||||||
onClose
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Bob hat auf deinen/i }).click();
|
|
||||||
|
|
||||||
expect(onClose).not.toHaveBeenCalled();
|
|
||||||
expect(goto).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls goto with annotationId appended when the notification has an annotationId', async () => {
|
|
||||||
const n = makeNotification({
|
|
||||||
id: 'n55',
|
|
||||||
documentId: 'd1',
|
|
||||||
referenceId: 'c1',
|
|
||||||
annotationId: 'a1',
|
|
||||||
actorName: 'Eva'
|
|
||||||
});
|
|
||||||
render(NotificationDropdown, {
|
|
||||||
props: {
|
|
||||||
notifications: [n],
|
|
||||||
optimisticMarkRead: () => {},
|
|
||||||
optimisticMarkAllRead: () => {},
|
|
||||||
onClose: () => {}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /Eva hat auf deinen/i }).click();
|
|
||||||
|
|
||||||
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1&annotationId=a1');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,46 +108,12 @@ describe('notificationStore (singleton)', () => {
|
|||||||
expect(notificationStore.unreadCount).toBe(1);
|
expect(notificationStore.unreadCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
|
it('markAllRead resets unreadCount', async () => {
|
||||||
notificationStore.init();
|
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
||||||
const notification = makeNotification({ id: 'sse-1', read: false });
|
await notificationStore.markAllRead();
|
||||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
|
||||||
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
|
|
||||||
|
|
||||||
notificationStore.optimisticMarkRead('sse-1');
|
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
|
||||||
|
|
||||||
expect(notificationStore.notifications[0].read).toBe(true);
|
|
||||||
expect(notificationStore.unreadCount).toBe(0);
|
expect(notificationStore.unreadCount).toBe(0);
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
|
|
||||||
notificationStore.init();
|
|
||||||
const notification = makeNotification({ id: 'sse-1', read: true });
|
|
||||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
|
||||||
|
|
||||||
notificationStore.optimisticMarkRead('sse-1');
|
|
||||||
|
|
||||||
expect(notificationStore.unreadCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
|
|
||||||
notificationStore.init();
|
|
||||||
lastEventSource!.simulate(
|
|
||||||
'notification',
|
|
||||||
JSON.stringify(makeNotification({ id: 'n1', read: false }))
|
|
||||||
);
|
|
||||||
lastEventSource!.simulate(
|
|
||||||
'notification',
|
|
||||||
JSON.stringify(makeNotification({ id: 'n2', read: false }))
|
|
||||||
);
|
|
||||||
mockFetch.mockReset();
|
|
||||||
|
|
||||||
notificationStore.optimisticMarkAllRead();
|
|
||||||
|
|
||||||
expect(notificationStore.unreadCount).toBe(0);
|
|
||||||
expect(notificationStore.notifications.every((n) => n.read)).toBe(true);
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,19 +35,28 @@ async function fetchUnreadCount(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function optimisticMarkRead(id: string): void {
|
async function markRead(notification: NotificationItem): Promise<void> {
|
||||||
const notification = notifications.find((n) => n.id === id);
|
if (!notification.read) {
|
||||||
if (notification && !notification.read) {
|
try {
|
||||||
notification.read = true;
|
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
||||||
unreadCount = Math.max(0, unreadCount - 1);
|
notification.read = true;
|
||||||
|
unreadCount = Math.max(0, unreadCount - 1);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to mark notification as read', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function optimisticMarkAllRead(): void {
|
async function markAllRead(): Promise<void> {
|
||||||
for (const n of notifications) {
|
try {
|
||||||
n.read = true;
|
await fetch('/api/notifications/read-all', { method: 'POST' });
|
||||||
|
for (const n of notifications) {
|
||||||
|
n.read = true;
|
||||||
|
}
|
||||||
|
unreadCount = 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to mark all notifications as read', e);
|
||||||
}
|
}
|
||||||
unreadCount = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function init(): void {
|
function init(): void {
|
||||||
@@ -114,8 +123,8 @@ export const notificationStore = {
|
|||||||
},
|
},
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
fetchUnreadCount,
|
fetchUnreadCount,
|
||||||
optimisticMarkRead,
|
markRead,
|
||||||
optimisticMarkAllRead,
|
markAllRead,
|
||||||
init,
|
init,
|
||||||
destroy
|
destroy
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import TrainingHistory from './TrainingHistory.svelte';
|
import TrainingHistory from './TrainingHistory.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { TrainingRun } from '$lib/ocr/training.js';
|
import type { TrainingRun } from '$lib/ocr/training.js';
|
||||||
import { withCsrf } from '$lib/shared/cookies';
|
|
||||||
|
|
||||||
interface TrainingInfo {
|
interface TrainingInfo {
|
||||||
availableBlocks?: number;
|
availableBlocks?: number;
|
||||||
@@ -34,7 +33,7 @@ async function startTraining() {
|
|||||||
successMessage = null;
|
successMessage = null;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ocr/train', withCsrf({ method: 'POST' }));
|
const res = await fetch('/api/ocr/train', { method: 'POST' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
successMessage = m.training_success();
|
successMessage = m.training_success();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import TrainingHistory from './TrainingHistory.svelte';
|
import TrainingHistory from './TrainingHistory.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { TrainingRun } from '$lib/ocr/training.js';
|
import type { TrainingRun } from '$lib/ocr/training.js';
|
||||||
import { withCsrf } from '$lib/shared/cookies';
|
|
||||||
|
|
||||||
interface TrainingInfo {
|
interface TrainingInfo {
|
||||||
availableSegBlocks?: number;
|
availableSegBlocks?: number;
|
||||||
@@ -28,7 +27,7 @@ async function startTraining() {
|
|||||||
training = true;
|
training = true;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ocr/segtrain', withCsrf({ method: 'POST' }));
|
const res = await fetch('/api/ocr/segtrain', { method: 'POST' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
successMessage = m.training_success();
|
successMessage = m.training_success();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import StammbaumSidePanel from './StammbaumSidePanel.svelte';
|
import StammbaumSidePanel from './StammbaumSidePanel.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }));
|
||||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||||
vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null }));
|
vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null }));
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,19 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import StammbaumSidePanel from './StammbaumSidePanel.svelte';
|
import StammbaumSidePanel from './StammbaumSidePanel.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { extractErrorCode } from './api.server';
|
|
||||||
|
|
||||||
describe('extractErrorCode', () => {
|
|
||||||
it('returns the code string when error has a code property', () => {
|
|
||||||
expect(extractErrorCode({ code: 'DOCUMENT_NOT_FOUND' })).toBe('DOCUMENT_NOT_FOUND');
|
|
||||||
});
|
|
||||||
it('returns undefined when error is undefined', () => {
|
|
||||||
expect(extractErrorCode(undefined)).toBeUndefined();
|
|
||||||
});
|
|
||||||
it('returns undefined when error is null', () => {
|
|
||||||
expect(extractErrorCode(null)).toBeUndefined();
|
|
||||||
});
|
|
||||||
it('returns undefined when error is a plain string', () => {
|
|
||||||
expect(extractErrorCode('oops')).toBeUndefined();
|
|
||||||
});
|
|
||||||
it('returns undefined when error object has no code property', () => {
|
|
||||||
expect(extractErrorCode({ message: 'fail' })).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -23,11 +23,3 @@ export function createApiClient(fetch: typeof globalThis.fetch) {
|
|||||||
fetch
|
fetch
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiError {
|
|
||||||
code?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractErrorCode(error: unknown): string | undefined {
|
|
||||||
return (error as ApiError | undefined)?.code;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,46 +1,3 @@
|
|||||||
/**
|
|
||||||
* Reads the XSRF-TOKEN cookie set by Spring Security's CookieCsrfTokenRepository.
|
|
||||||
* Returns null outside the browser or when the cookie is absent.
|
|
||||||
*/
|
|
||||||
export function getCsrfToken(): string | null {
|
|
||||||
if (typeof document === 'undefined') return null;
|
|
||||||
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
|
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merges the X-XSRF-TOKEN header into a RequestInit so Spring Security's
|
|
||||||
* CSRF filter accepts the request. Safe to call server-side (no-op when the
|
|
||||||
* cookie is absent).
|
|
||||||
*/
|
|
||||||
export function withCsrf(init?: RequestInit): RequestInit {
|
|
||||||
const token = getCsrfToken();
|
|
||||||
if (!token) return init ?? {};
|
|
||||||
const headers = new Headers(init?.headers);
|
|
||||||
headers.set('X-XSRF-TOKEN', token);
|
|
||||||
return { ...init, headers };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps a fetch implementation so that every state-mutating call (POST, PUT,
|
|
||||||
* PATCH, DELETE) automatically includes the X-XSRF-TOKEN header. GET/HEAD
|
|
||||||
* requests pass through unchanged.
|
|
||||||
*
|
|
||||||
* Used to CSRF-protect client-side hooks that accept an injectable fetchImpl.
|
|
||||||
* In unit tests the injected mock is wrapped but getCsrfToken() returns null
|
|
||||||
* (no browser cookie), so no header is added and existing test expectations
|
|
||||||
* are unaffected.
|
|
||||||
*/
|
|
||||||
export function makeCsrfFetch(inner: typeof fetch): typeof fetch {
|
|
||||||
return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
||||||
const method = (init?.method ?? 'GET').toUpperCase();
|
|
||||||
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
||||||
return inner(input, withCsrf(init));
|
|
||||||
}
|
|
||||||
return inner(input, init);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the fa_session cookie value from a list of Set-Cookie response headers.
|
* Extracts the fa_session cookie value from a list of Set-Cookie response headers.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { page } from 'vitest/browser';
|
|||||||
import DocumentList from './DocumentList.svelte';
|
import DocumentList from './DocumentList.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { default: DocumentList } = await import('./DocumentList.svelte');
|
const { default: DocumentList } = await import('./DocumentList.svelte');
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import { invalidateAll } from '$app/navigation';
|
|
||||||
|
|
||||||
import DropZone from './DropZone.svelte';
|
import DropZone from './DropZone.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
// vi.hoisted lets the mock fn reference survive vi.mock's hoisting so tests
|
||||||
|
// can assert on it from below while the factory remains self-contained.
|
||||||
|
const { invalidateAllMock } = vi.hoisted(() => ({ invalidateAllMock: vi.fn(async () => {}) }));
|
||||||
|
vi.mock('$app/navigation', () => ({ invalidateAll: invalidateAllMock }));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -66,7 +68,7 @@ describe('DropZone onUploadComplete', () => {
|
|||||||
// invalidateAll is the last async step of the upload handler — once it
|
// invalidateAll is the last async step of the upload handler — once it
|
||||||
// has been called, the callback decision has already been made.
|
// has been called, the callback decision has already been made.
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(vi.mocked(invalidateAll)).toHaveBeenCalled();
|
expect(invalidateAllMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
expect(onUploadComplete).not.toHaveBeenCalled();
|
expect(onUploadComplete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { default: DropZone } = await import('./DropZone.svelte');
|
const { default: DropZone } = await import('./DropZone.svelte');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
@@ -34,16 +34,16 @@ export async function load({ fetch, locals }) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!usersResult.response.ok) {
|
if (!usersResult.response.ok) {
|
||||||
throw error(usersResult.response.status, getErrorMessage(extractErrorCode(usersResult.error)));
|
const code = (usersResult.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(usersResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
if (!groupsResult.response.ok) {
|
if (!groupsResult.response.ok) {
|
||||||
throw error(
|
const code = (groupsResult.error as unknown as { code?: string })?.code;
|
||||||
groupsResult.response.status,
|
throw error(groupsResult.response.status, getErrorMessage(code));
|
||||||
getErrorMessage(extractErrorCode(groupsResult.error))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!tagsResult.response.ok) {
|
if (!tagsResult.response.ok) {
|
||||||
throw error(tagsResult.response.status, getErrorMessage(extractErrorCode(tagsResult.error)));
|
const code = (tagsResult.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(tagsResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
let inviteCount = 0;
|
let inviteCount = 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, parent }) => {
|
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||||
@@ -24,9 +24,8 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, {
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
error: getErrorMessage(extractErrorCode(result.error))
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -39,9 +38,8 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, {
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
error: getErrorMessage(extractErrorCode(result.error))
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, '/admin/groups');
|
throw redirect(303, '/admin/groups');
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ const mockApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({
|
||||||
createApiClient: () => mockApi,
|
createApiClient: () => mockApi
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Page from './+page.svelte';
|
|||||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||||
|
|
||||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||||
|
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import { load } from './+layout.server';
|
import { load } from './+layout.server';
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||||
createApiClient: vi.fn(),
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
@@ -16,9 +16,8 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, {
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
error: getErrorMessage(extractErrorCode(result.error))
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, '/admin/groups');
|
throw redirect(303, '/admin/groups');
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ vi.mock('$app/forms', () => ({
|
|||||||
return { destroy: vi.fn() };
|
return { destroy: vi.fn() };
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||||
|
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { default: AdminGroupNewPage } = await import('./+page.svelte');
|
const { default: AdminGroupNewPage } = await import('./+page.svelte');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
@@ -25,7 +25,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
|
|||||||
let invites: InviteListItem[] = [];
|
let invites: InviteListItem[] = [];
|
||||||
let loadError: string | null = null;
|
let loadError: string | null = null;
|
||||||
if (!invitesResult.response.ok) {
|
if (!invitesResult.response.ok) {
|
||||||
loadError = extractErrorCode(invitesResult.error) ?? 'INTERNAL_ERROR';
|
const code = (invitesResult.error as unknown as { code?: string })?.code;
|
||||||
|
loadError = code ?? 'INTERNAL_ERROR';
|
||||||
} else {
|
} else {
|
||||||
invites = (invitesResult.data ?? []) as InviteListItem[];
|
invites = (invitesResult.data ?? []) as InviteListItem[];
|
||||||
}
|
}
|
||||||
@@ -33,7 +34,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
|
|||||||
let groups: UserGroup[] = [];
|
let groups: UserGroup[] = [];
|
||||||
let groupsLoadError: string | null = null;
|
let groupsLoadError: string | null = null;
|
||||||
if (!groupsResult.response.ok) {
|
if (!groupsResult.response.ok) {
|
||||||
groupsLoadError = extractErrorCode(groupsResult.error) ?? 'INTERNAL_ERROR';
|
const code = (groupsResult.error as unknown as { code?: string })?.code;
|
||||||
|
groupsLoadError = code ?? 'INTERNAL_ERROR';
|
||||||
} else {
|
} else {
|
||||||
const raw = groupsResult.data ?? [];
|
const raw = groupsResult.data ?? [];
|
||||||
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
@@ -60,9 +62,8 @@ export const actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, {
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
createError: extractErrorCode(result.error) ?? 'INTERNAL_ERROR'
|
return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created: result.data! as InviteListItem };
|
return { created: result.data! as InviteListItem };
|
||||||
@@ -77,9 +78,8 @@ export const actions = {
|
|||||||
const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } });
|
const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } });
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, {
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
revokeError: extractErrorCode(result.error) ?? 'INTERNAL_ERROR'
|
return fail(result.response.status, { revokeError: code ?? 'INTERNAL_ERROR' });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { revoked: id };
|
return { revoked: id };
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import { load } from './+layout.server';
|
import { load } from './+layout.server';
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||||
createApiClient: vi.fn(),
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch }) => {
|
export const load: PageServerLoad = async ({ fetch }) => {
|
||||||
@@ -8,7 +8,8 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
|||||||
const result = await api.GET('/api/ocr/training-info');
|
const result = await api.GET('/api/ocr/training-info');
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { trainingInfo: result.data! };
|
return { trainingInfo: result.data! };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||||
@@ -10,7 +10,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { history: result.data!, personId: params.personId };
|
return { history: result.data!, personId: params.personId };
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import { load } from './+page.server';
|
|||||||
|
|
||||||
const mockApi = { GET: vi.fn() };
|
const mockApi = { GET: vi.fn() };
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
|
||||||
createApiClient: () => mockApi,
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch }) => {
|
export const load: PageServerLoad = async ({ fetch }) => {
|
||||||
@@ -8,7 +8,8 @@ export const load: PageServerLoad = async ({ fetch }) => {
|
|||||||
const result = await api.GET('/api/ocr/training-info/global');
|
const result = await api.GET('/api/ocr/training-info/global');
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { history: result.data! };
|
return { history: result.data! };
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import { load } from './+page.server';
|
|||||||
|
|
||||||
const mockApi = { GET: vi.fn() };
|
const mockApi = { GET: vi.fn() };
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
|
||||||
createApiClient: () => mockApi,
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import { load } from './+page.server';
|
|||||||
|
|
||||||
const mockApi = { GET: vi.fn() };
|
const mockApi = { GET: vi.fn() };
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
|
||||||
createApiClient: () => mockApi,
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
const fullData = {
|
const fullData = {
|
||||||
userCount: 4,
|
userCount: 4,
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { default: AdminEntryPage } = await import('./+page.svelte');
|
const { default: AdminEntryPage } = await import('./+page.svelte');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, parent, url }) => {
|
export const load: PageServerLoad = async ({ params, parent, url }) => {
|
||||||
@@ -25,9 +25,8 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, {
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
error: getErrorMessage(extractErrorCode(result.error))
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -44,9 +43,8 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, {
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
error: getErrorMessage(extractErrorCode(result.error))
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, `/admin/tags/${result.data!.id}?merged=1`);
|
throw redirect(303, `/admin/tags/${result.data!.id}?merged=1`);
|
||||||
@@ -67,9 +65,8 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, {
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
error: getErrorMessage(extractErrorCode(result.error))
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, '/admin/tags');
|
throw redirect(303, '/admin/tags');
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ const mockApi = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({
|
||||||
createApiClient: () => mockApi,
|
createApiClient: () => mockApi
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import Page from './+page.svelte';
|
|||||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||||
|
|
||||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: vi.fn(),
|
||||||
|
goto: vi.fn(),
|
||||||
|
replaceState: vi.fn()
|
||||||
|
}));
|
||||||
vi.mock('$app/stores', () => ({
|
vi.mock('$app/stores', () => ({
|
||||||
page: {
|
page: {
|
||||||
subscribe: (fn: (v: { url: URL }) => void) => {
|
subscribe: (fn: (v: { url: URL }) => void) => {
|
||||||
|
|||||||
@@ -17,7 +17,19 @@ vi.mock('$lib/shared/services/confirm.svelte', () => ({
|
|||||||
getConfirmService: () => ({ confirm: async () => false })
|
getConfirmService: () => ({ confirm: async () => false })
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { default: AdminTagEditPage } = await import('./+page.svelte');
|
const { default: AdminTagEditPage } = await import('./+page.svelte');
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import { load } from './+layout.server';
|
import { load } from './+layout.server';
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||||
createApiClient: vi.fn(),
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
@@ -55,9 +55,8 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, {
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
error: getErrorMessage(extractErrorCode(result.error))
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -70,9 +69,8 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, {
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
error: getErrorMessage(extractErrorCode(result.error))
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, '/admin/users');
|
throw redirect(303, '/admin/users');
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ vi.mock('$env/dynamic/private', () => ({
|
|||||||
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||||
createApiClient: vi.fn(),
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { load, actions } from './+page.server';
|
import { load, actions } from './+page.server';
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ vi.mock('$app/forms', () => ({
|
|||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||||
|
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import { load } from './+layout.server';
|
import { load } from './+layout.server';
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||||
createApiClient: vi.fn(),
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||||
@@ -35,9 +35,8 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
return fail(result.response.status, {
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
error: getErrorMessage(extractErrorCode(result.error))
|
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect(303, '/admin/users');
|
throw redirect(303, '/admin/users');
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ vi.mock('$app/forms', () => ({
|
|||||||
return { destroy: vi.fn() };
|
return { destroy: vi.fn() };
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||||
|
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { default: AdminUserNewPage } = await import('./+page.svelte');
|
const { default: AdminUserNewPage } = await import('./+page.svelte');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
|
||||||
import type { components, operations } from '$lib/generated/api';
|
import type { components, operations } from '$lib/generated/api';
|
||||||
|
|
||||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||||
@@ -67,31 +65,3 @@ export async function load({ fetch, url }) {
|
|||||||
loadError
|
loadError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const actions = {
|
|
||||||
'dismiss-notification': async ({ request, fetch }) => {
|
|
||||||
const data = await request.formData();
|
|
||||||
const raw = data.get('notificationId');
|
|
||||||
const notificationId = typeof raw === 'string' ? raw : null;
|
|
||||||
if (!notificationId) return fail(400, { error: getErrorMessage(undefined) });
|
|
||||||
const api = createApiClient(fetch);
|
|
||||||
const result = await api.PATCH('/api/notifications/{id}/read', {
|
|
||||||
params: { path: { id: notificationId } }
|
|
||||||
});
|
|
||||||
if (!result.response.ok) {
|
|
||||||
const code = (result.error as unknown as { code?: string })?.code;
|
|
||||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
|
|
||||||
'mark-all-read': async ({ fetch }) => {
|
|
||||||
const api = createApiClient(fetch);
|
|
||||||
const result = await api.POST('/api/notifications/read-all');
|
|
||||||
if (!result.response.ok) {
|
|
||||||
const code = (result.error as unknown as { code?: string })?.code;
|
|
||||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ async function onFilterChange(v: FilterValue) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onMarkRead(n: NotificationItem) {
|
||||||
|
await notificationStore.markRead(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMarkAllRead() {
|
||||||
|
await notificationStore.markAllRead();
|
||||||
|
}
|
||||||
|
|
||||||
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
|
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
|
||||||
|
|
||||||
const isEmpty = $derived(displayFeed.length === 0);
|
const isEmpty = $derived(displayFeed.length === 0);
|
||||||
@@ -100,11 +108,7 @@ function retry() {
|
|||||||
{#if data.loadError === 'activity'}
|
{#if data.loadError === 'activity'}
|
||||||
<ChronikErrorCard onRetry={retry} />
|
<ChronikErrorCard onRetry={retry} />
|
||||||
{:else}
|
{:else}
|
||||||
<ChronikFuerDichBox
|
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
|
||||||
unread={unread}
|
|
||||||
optimisticMarkRead={notificationStore.optimisticMarkRead}
|
|
||||||
optimisticMarkAllRead={notificationStore.optimisticMarkAllRead}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
|
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { load, actions } from './+page.server';
|
import { load } from './+page.server';
|
||||||
|
|
||||||
const mockApi = {
|
const mockApi = {
|
||||||
GET: vi.fn(),
|
GET: vi.fn()
|
||||||
PATCH: vi.fn(),
|
|
||||||
POST: vi.fn()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({
|
||||||
createApiClient: () => mockApi,
|
createApiClient: () => mockApi
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function buildUrl(search = ''): URL {
|
function buildUrl(search = ''): URL {
|
||||||
@@ -176,84 +173,3 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
|||||||
expect(call[1].params.query.kinds).toHaveLength(2);
|
expect(call[1].params.query.kinds).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function makeActionEvent(formData: FormData): any {
|
|
||||||
return {
|
|
||||||
request: new Request('http://localhost/aktivitaeten', { method: 'POST', body: formData }),
|
|
||||||
fetch
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('aktivitaeten/actions — dismiss-notification', () => {
|
|
||||||
it('returns fail(400, { error }) and does NOT call PATCH when notificationId is missing', async () => {
|
|
||||||
const result = await actions['dismiss-notification'](makeActionEvent(new FormData()));
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: 400 });
|
|
||||||
expect(mockApi.PATCH).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls PATCH /api/notifications/{id}/read with the form-supplied notificationId', async () => {
|
|
||||||
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.set('notificationId', 'n-abc');
|
|
||||||
|
|
||||||
await actions['dismiss-notification'](makeActionEvent(fd));
|
|
||||||
|
|
||||||
expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', {
|
|
||||||
params: { path: { id: 'n-abc' } }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns { success: true } when the API responds ok', async () => {
|
|
||||||
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.set('notificationId', 'n-abc');
|
|
||||||
|
|
||||||
const result = await actions['dismiss-notification'](makeActionEvent(fd));
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fail(status, { error }) when the API responds non-ok', async () => {
|
|
||||||
mockApi.PATCH.mockResolvedValue({
|
|
||||||
response: { ok: false, status: 403 },
|
|
||||||
error: { code: 'NOTIFICATION_NOT_FOUND' }
|
|
||||||
});
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.set('notificationId', 'n-abc');
|
|
||||||
|
|
||||||
const result = await actions['dismiss-notification'](makeActionEvent(fd));
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: 403 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('aktivitaeten/actions — mark-all-read', () => {
|
|
||||||
it('calls POST /api/notifications/read-all', async () => {
|
|
||||||
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
|
|
||||||
|
|
||||||
await actions['mark-all-read'](makeActionEvent(new FormData()));
|
|
||||||
|
|
||||||
expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns { success: true } when the API responds ok', async () => {
|
|
||||||
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
|
|
||||||
|
|
||||||
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns fail(status, { error }) when the API responds non-ok', async () => {
|
|
||||||
mockApi.POST.mockResolvedValue({
|
|
||||||
response: { ok: false, status: 500 },
|
|
||||||
error: { code: 'INTERNAL_ERROR' }
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ status: 500 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -14,7 +14,19 @@ vi.mock('$app/state', () => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/notification/notifications.svelte', () => ({
|
vi.mock('$lib/notification/notifications.svelte', () => ({
|
||||||
notificationStore: {
|
notificationStore: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
|
||||||
export async function load({ url, fetch, locals }) {
|
export async function load({ url, fetch, locals }) {
|
||||||
@@ -39,7 +39,8 @@ export async function load({ url, fetch, locals }) {
|
|||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
documents = result.data ?? [];
|
documents = result.data ?? [];
|
||||||
})
|
})
|
||||||
@@ -48,7 +49,8 @@ export async function load({ url, fetch, locals }) {
|
|||||||
requests.push(
|
requests.push(
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then((result) => {
|
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then((result) => {
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
const p = result.data as { displayName: string } | undefined;
|
const p = result.data as { displayName: string } | undefined;
|
||||||
if (p) senderName = p.displayName;
|
if (p) senderName = p.displayName;
|
||||||
@@ -60,7 +62,8 @@ export async function load({ url, fetch, locals }) {
|
|||||||
requests.push(
|
requests.push(
|
||||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then((result) => {
|
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then((result) => {
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
const p = result.data as { displayName: string } | undefined;
|
const p = result.data as { displayName: string } | undefined;
|
||||||
if (p) receiverName = p.displayName;
|
if (p) receiverName = p.displayName;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import CorrespondenzHero from './CorrespondenzHero.svelte';
|
import CorrespondenzHero from './CorrespondenzHero.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import { load } from './+page.server';
|
import { load } from './+page.server';
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||||
createApiClient: vi.fn(),
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
vi.mock('$lib/shared/errors', () => ({
|
vi.mock('$lib/shared/errors', () => ({
|
||||||
getErrorMessage: (code: string) => code ?? 'Unknown error'
|
getErrorMessage: (code: string) => code ?? 'Unknown error'
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { default: BriefwechselPage } = await import('./+page.svelte');
|
const { default: BriefwechselPage } = await import('./+page.svelte');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
@@ -103,7 +103,8 @@ export async function load({ url, fetch }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage: string | null = !result.response.ok
|
const errorMessage: string | null = !result.response.ok
|
||||||
? (getErrorMessage(extractErrorCode(result.error)) ?? 'Daten konnten nicht geladen werden.')
|
? (getErrorMessage((result.error as unknown as { code?: string })?.code) ??
|
||||||
|
'Daten konnten nicht geladen werden.')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import { inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
import { inferredRelationshipLabel } from '$lib/person/relationshipLabels';
|
||||||
|
|
||||||
@@ -17,7 +17,8 @@ export async function load({ params, fetch }) {
|
|||||||
if (docResult.response.status === 401) throw redirect(302, '/login');
|
if (docResult.response.status === 401) throw redirect(302, '/login');
|
||||||
|
|
||||||
if (!docResult.response.ok) {
|
if (!docResult.response.ok) {
|
||||||
throw error(docResult.response.status, getErrorMessage(extractErrorCode(docResult.error)));
|
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(docResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = docResult.data!;
|
const document = docResult.data!;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
|
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
|
||||||
|
|
||||||
export async function load({
|
export async function load({
|
||||||
@@ -30,7 +30,8 @@ export async function load({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!docResult.response.ok) {
|
if (!docResult.response.ok) {
|
||||||
throw error(docResult.response.status, getErrorMessage(extractErrorCode(docResult.error)));
|
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(docResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
if (!personsResult.response.ok) {
|
if (!personsResult.response.ok) {
|
||||||
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
|
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
|
||||||
@@ -75,9 +76,8 @@ export const actions = {
|
|||||||
// Fetch current document to preserve all existing fields
|
// Fetch current document to preserve all existing fields
|
||||||
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id: params.id } } });
|
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id: params.id } } });
|
||||||
if (!docResult.response.ok) {
|
if (!docResult.response.ok) {
|
||||||
return fail(docResult.response.status, {
|
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||||
error: getErrorMessage(extractErrorCode(docResult.error))
|
return fail(docResult.response.status, { error: getErrorMessage(code) });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doc = docResult.data!;
|
const doc = docResult.data!;
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||||
createApiClient: vi.fn(),
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
vi.mock('$env/dynamic/private', () => ({ env: { API_INTERNAL_URL: 'http://test-backend:8080' } }));
|
vi.mock('$env/dynamic/private', () => ({ env: { API_INTERNAL_URL: 'http://test-backend:8080' } }));
|
||||||
|
|
||||||
import { load } from './+page.server';
|
import { load } from './+page.server';
|
||||||
|
|||||||
@@ -13,7 +13,19 @@ vi.mock('$app/state', () => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/shared/services/confirm.svelte', () => ({
|
vi.mock('$lib/shared/services/confirm.svelte', () => ({
|
||||||
getConfirmService: () => ({ confirm: async () => false })
|
getConfirmService: () => ({ confirm: async () => false })
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
const gotoSpy = vi.fn();
|
||||||
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: gotoSpy,
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { bulkSelectionStore } = await import('$lib/document/bulkSelection.svelte');
|
const { bulkSelectionStore } = await import('$lib/document/bulkSelection.svelte');
|
||||||
const { default: BulkEditPage } = await import('./+page.svelte');
|
const { default: BulkEditPage } = await import('./+page.svelte');
|
||||||
@@ -11,14 +23,14 @@ const { default: BulkEditPage } = await import('./+page.svelte');
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
bulkSelectionStore.clear();
|
bulkSelectionStore.clear();
|
||||||
vi.mocked(goto).mockClear();
|
gotoSpy.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('documents/bulk-edit page', () => {
|
describe('documents/bulk-edit page', () => {
|
||||||
it('redirects to /documents when no documents are selected', async () => {
|
it('redirects to /documents when no documents are selected', async () => {
|
||||||
render(BulkEditPage, { props: {} });
|
render(BulkEditPage, { props: {} });
|
||||||
|
|
||||||
await vi.waitFor(() => expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents'));
|
await vi.waitFor(() => expect(gotoSpy).toHaveBeenCalledWith('/documents'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the loading spinner while fetching batch metadata', async () => {
|
it('shows the loading spinner while fetching batch metadata', async () => {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||||
createApiClient: vi.fn(),
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { load } from './+page.server';
|
import { load } from './+page.server';
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
vi.mock('$app/state', () => ({ navigating: { to: null } }));
|
vi.mock('$app/state', () => ({ navigating: { to: null } }));
|
||||||
|
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|||||||
@@ -4,7 +4,19 @@ import { page } from 'vitest/browser';
|
|||||||
|
|
||||||
const mockNavigating = { to: null };
|
const mockNavigating = { to: null };
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('$app/state', () => ({
|
vi.mock('$app/state', () => ({
|
||||||
get navigating() {
|
get navigating() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
|
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
|
||||||
|
|
||||||
export async function load({
|
export async function load({
|
||||||
@@ -31,7 +31,8 @@ export async function load({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!docResult.response.ok) {
|
if (!docResult.response.ok) {
|
||||||
throw error(docResult.response.status, getErrorMessage(extractErrorCode(docResult.error)));
|
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(docResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
const incompleteCount = countResult.response.ok ? (countResult.data?.count ?? 0) : 0;
|
const incompleteCount = countResult.response.ok ? (countResult.data?.count ?? 0) : 0;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
@@ -25,7 +25,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!listResult.response.ok) {
|
if (!listResult.response.ok) {
|
||||||
throw error(listResult.response.status, getErrorMessage(extractErrorCode(listResult.error)));
|
const code = (listResult.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(listResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
const personFilters = personResults
|
const personFilters = personResults
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
@@ -9,7 +9,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
|
|||||||
params: { path: { id: params.id } }
|
params: { path: { id: params.id } }
|
||||||
});
|
});
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
return { geschichte: result.data! };
|
return { geschichte: result.data! };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
@@ -13,7 +13,8 @@ export const load: PageServerLoad = async ({ params, fetch, parent }) => {
|
|||||||
params: { path: { id: params.id } }
|
params: { path: { id: params.id } }
|
||||||
});
|
});
|
||||||
if (!result.response.ok) {
|
if (!result.response.ok) {
|
||||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
const code = (result.error as unknown as { code?: string })?.code;
|
||||||
|
throw error(result.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
return { geschichte: result.data! };
|
return { geschichte: result.data! };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { default: GeschichtenEditPage } = await import('./+page.svelte');
|
const { default: GeschichtenEditPage } = await import('./+page.svelte');
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { default: GeschichtenNewPage } = await import('./+page.svelte');
|
const { default: GeschichtenNewPage } = await import('./+page.svelte');
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
vi.mock('$app/state', () => ({ navigating: { to: null } }));
|
vi.mock('$app/state', () => ({ navigating: { to: null } }));
|
||||||
|
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: () => {},
|
||||||
|
afterNavigate: () => {},
|
||||||
|
goto: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
invalidateAll: vi.fn(),
|
||||||
|
preloadCode: vi.fn(),
|
||||||
|
preloadData: vi.fn(),
|
||||||
|
pushState: vi.fn(),
|
||||||
|
replaceState: vi.fn(),
|
||||||
|
disableScrollHandling: vi.fn(),
|
||||||
|
onNavigate: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
const { default: GeschichtenListPage } = await import('./+page.svelte');
|
const { default: GeschichtenListPage } = await import('./+page.svelte');
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
vi.mock('$lib/shared/api.server', () => ({
|
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||||
createApiClient: vi.fn(),
|
|
||||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { load } from './+page.server';
|
import { load } from './+page.server';
|
||||||
import { createApiClient } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type User = components['schemas']['AppUser'];
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
vi.mock('$app/navigation');
|
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
||||||
|
|
||||||
const baseUser: User = {
|
const baseUser: User = {
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
import { createApiClient } from '$lib/shared/api.server';
|
||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
|
|
||||||
export async function load({ params, fetch, locals }) {
|
export async function load({ params, fetch, locals }) {
|
||||||
@@ -32,10 +32,8 @@ export async function load({ params, fetch, locals }) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!personResult.response.ok) {
|
if (!personResult.response.ok) {
|
||||||
throw error(
|
const code = (personResult.error as unknown as { code?: string })?.code;
|
||||||
personResult.response.status,
|
throw error(personResult.response.status, getErrorMessage(code));
|
||||||
getErrorMessage(extractErrorCode(personResult.error))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user