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 |
@@ -39,12 +39,6 @@ PORT_PROMETHEUS=9090
|
||||
# Grafana admin password — change this before exposing Grafana beyond localhost
|
||||
GRAFANA_ADMIN_PASSWORD=changeme
|
||||
|
||||
# Password for the read-only grafana_reader PostgreSQL role used by the PO
|
||||
# Overview dashboard. Consumed by Flyway V68 (to set the role's password) and
|
||||
# by Grafana's PostgreSQL datasource (to connect). REQUIRED in production —
|
||||
# generate with: openssl rand -hex 32
|
||||
GRAFANA_DB_PASSWORD=changeme-generate-with-openssl-rand-hex-32
|
||||
|
||||
# GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
|
||||
GLITCHTIP_DOMAIN=http://localhost:3002
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ name: nightly
|
||||
# STAGING_APP_ADMIN_USERNAME
|
||||
# STAGING_APP_ADMIN_PASSWORD
|
||||
# GRAFANA_ADMIN_PASSWORD
|
||||
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
|
||||
# GLITCHTIP_SECRET_KEY
|
||||
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
||||
|
||||
@@ -80,8 +79,6 @@ jobs:
|
||||
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
|
||||
POSTGRES_USER=archiv
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
VITE_SENTRY_DSN=${{ secrets.VITE_SENTRY_DSN }}
|
||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
EOF
|
||||
|
||||
- name: Verify backend /import:ro mount is wired
|
||||
@@ -145,7 +142,6 @@ jobs:
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||
POSTGRES_HOST=archiv-staging-db-1
|
||||
|
||||
@@ -35,7 +35,6 @@ name: release
|
||||
# MAIL_USERNAME
|
||||
# MAIL_PASSWORD
|
||||
# GRAFANA_ADMIN_PASSWORD
|
||||
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
|
||||
# GLITCHTIP_SECRET_KEY
|
||||
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
||||
|
||||
@@ -78,7 +77,6 @@ jobs:
|
||||
IMPORT_HOST_DIR=/srv/familienarchiv-production/import
|
||||
POSTGRES_USER=archiv
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
EOF
|
||||
|
||||
- name: Build images
|
||||
@@ -112,7 +110,6 @@ jobs:
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||
POSTGRES_HOST=archiv-production-db-1
|
||||
|
||||
@@ -7,15 +7,12 @@ import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Map;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FlywayConfig {
|
||||
|
||||
private static final String GRAFANA_DB_PASSWORD_FALLBACK = "changeme-grafana-db-password";
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
@Bean(name = "flyway")
|
||||
@@ -24,7 +21,6 @@ public class FlywayConfig {
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration")
|
||||
.placeholders(Map.of("grafanaDbPassword", resolveGrafanaDbPassword()))
|
||||
.baselineOnMigrate(true)
|
||||
.baselineVersion("4")
|
||||
.load();
|
||||
@@ -32,14 +28,4 @@ public class FlywayConfig {
|
||||
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
|
||||
return flyway;
|
||||
}
|
||||
|
||||
private String resolveGrafanaDbPassword() {
|
||||
String value = System.getenv("GRAFANA_DB_PASSWORD");
|
||||
if (value == null || value.isBlank()) {
|
||||
log.warn("GRAFANA_DB_PASSWORD is not set; the grafana_reader role will use a non-secret fallback. "
|
||||
+ "Set GRAFANA_DB_PASSWORD in production to enable the Grafana PostgreSQL datasource.");
|
||||
return GRAFANA_DB_PASSWORD_FALLBACK;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,14 +25,11 @@ import java.util.UUID;
|
||||
@NamedEntityGraph(name = "Document.full", attributeNodes = {
|
||||
@NamedAttributeNode("sender"),
|
||||
@NamedAttributeNode("receivers"),
|
||||
@NamedAttributeNode("tags"),
|
||||
@NamedAttributeNode("trainingLabels")
|
||||
@NamedAttributeNode("tags")
|
||||
})
|
||||
@NamedEntityGraph(name = "Document.list", attributeNodes = {
|
||||
@NamedAttributeNode("sender"),
|
||||
@NamedAttributeNode("receivers"),
|
||||
@NamedAttributeNode("tags"),
|
||||
@NamedAttributeNode("trainingLabels")
|
||||
@NamedAttributeNode("tags")
|
||||
})
|
||||
@Entity
|
||||
@Table(name = "documents")
|
||||
|
||||
@@ -43,7 +43,7 @@ public class TranscriptionBlockController {
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public TranscriptionBlock createBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
||||
@@ -53,7 +53,7 @@ public class TranscriptionBlockController {
|
||||
}
|
||||
|
||||
@PutMapping("/{blockId}")
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public TranscriptionBlock updateBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId,
|
||||
@@ -65,7 +65,7 @@ public class TranscriptionBlockController {
|
||||
|
||||
@DeleteMapping("/{blockId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public void deleteBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId) {
|
||||
@@ -73,7 +73,7 @@ public class TranscriptionBlockController {
|
||||
}
|
||||
|
||||
@PutMapping("/reorder")
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public List<TranscriptionBlock> reorderBlocks(
|
||||
@PathVariable UUID documentId,
|
||||
@RequestBody ReorderTranscriptionBlocksDTO dto) {
|
||||
@@ -82,7 +82,7 @@ public class TranscriptionBlockController {
|
||||
}
|
||||
|
||||
@PutMapping("/{blockId}/review")
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public TranscriptionBlock reviewBlock(
|
||||
@PathVariable UUID documentId,
|
||||
@PathVariable UUID blockId,
|
||||
@@ -92,7 +92,7 @@ public class TranscriptionBlockController {
|
||||
}
|
||||
|
||||
@PutMapping("/review-all")
|
||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public List<TranscriptionBlock> markAllBlocksReviewed(
|
||||
@PathVariable UUID documentId,
|
||||
Authentication authentication) {
|
||||
|
||||
@@ -56,17 +56,9 @@ public class MassImportService {
|
||||
|
||||
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(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) SkipReason reason
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
|
||||
) {}
|
||||
|
||||
public record ImportStatus(
|
||||
@@ -299,11 +291,6 @@ public class MassImportService {
|
||||
if (index.isBlank()) continue;
|
||||
|
||||
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);
|
||||
if (fileOnDisk.isEmpty()) {
|
||||
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
|
||||
@@ -313,17 +300,17 @@ public class MassImportService {
|
||||
try {
|
||||
if (!isPdfMagicBytes(fileOnDisk.get())) {
|
||||
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;
|
||||
}
|
||||
} catch (IOException 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;
|
||||
}
|
||||
}
|
||||
|
||||
Optional<SkipReason> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
|
||||
Optional<String> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
|
||||
if (skipReason.isPresent()) {
|
||||
skippedFiles.add(new SkippedFile(filename, skipReason.get()));
|
||||
} else {
|
||||
@@ -333,23 +320,6 @@ public class MassImportService {
|
||||
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
|
||||
InputStream openFileStream(File file) throws IOException {
|
||||
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.
|
||||
*/
|
||||
@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);
|
||||
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
|
||||
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
|
||||
return Optional.of(SkipReason.ALREADY_EXISTS);
|
||||
return Optional.of("ALREADY_EXISTS");
|
||||
}
|
||||
|
||||
String archiveBox = getCell(cells, colBox);
|
||||
@@ -412,7 +382,7 @@ public class MassImportService {
|
||||
status = DocumentStatus.UPLOADED;
|
||||
} catch (Exception 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) {
|
||||
File baseDir = new File(importDir);
|
||||
try (Stream<Path> walk = Files.walk(baseDir.toPath())) {
|
||||
Optional<Path> match = walk.filter(p -> !Files.isDirectory(p))
|
||||
try (Stream<Path> walk = Files.walk(Paths.get(importDir))) {
|
||||
return walk.filter(p -> !Files.isDirectory(p))
|
||||
.filter(p -> p.getFileName().toString().equals(filename))
|
||||
.map(Path::toFile)
|
||||
.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) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
-- Read-only role used by the Grafana PostgreSQL datasource for the PO Overview
|
||||
-- dashboard (issue #651). Password is injected at migration time via the Flyway
|
||||
-- placeholder ${grafanaDbPassword}, supplied by FlywayConfig from the
|
||||
-- GRAFANA_DB_PASSWORD environment variable.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'grafana_reader') THEN
|
||||
EXECUTE format('CREATE ROLE grafana_reader WITH LOGIN PASSWORD %L', '${grafanaDbPassword}');
|
||||
ELSE
|
||||
EXECUTE format('ALTER ROLE grafana_reader WITH LOGIN PASSWORD %L', '${grafanaDbPassword}');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
GRANT CONNECT ON DATABASE ${flyway:database} TO grafana_reader;
|
||||
GRANT USAGE ON SCHEMA public TO grafana_reader;
|
||||
GRANT SELECT ON audit_log, documents, transcription_blocks TO grafana_reader;
|
||||
@@ -1,47 +0,0 @@
|
||||
package org.raddatz.familienarchiv.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class GrafanaReaderRoleIntegrationTest {
|
||||
|
||||
@Autowired JdbcTemplate jdbc;
|
||||
|
||||
@Test
|
||||
void grafana_reader_has_select_on_audit_log() {
|
||||
assertThat(hasSelect("audit_log")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grafana_reader_has_select_on_documents() {
|
||||
assertThat(hasSelect("documents")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grafana_reader_has_select_on_transcription_blocks() {
|
||||
assertThat(hasSelect("transcription_blocks")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grafana_reader_has_no_select_on_app_users() {
|
||||
assertThat(hasSelect("app_users")).isFalse();
|
||||
}
|
||||
|
||||
private boolean hasSelect(String table) {
|
||||
Boolean result = jdbc.queryForObject(
|
||||
"SELECT has_table_privilege('grafana_reader', ?, 'SELECT')",
|
||||
Boolean.class,
|
||||
table);
|
||||
return Boolean.TRUE.equals(result);
|
||||
}
|
||||
}
|
||||
@@ -154,10 +154,10 @@ class MassImportServiceTest {
|
||||
.build();
|
||||
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());
|
||||
assertThat(result).isPresent().contains(MassImportService.SkipReason.ALREADY_EXISTS);
|
||||
assertThat(result).isPresent().contains("ALREADY_EXISTS");
|
||||
}
|
||||
|
||||
// ─── importSingleDocument — already-exists guard fires before file I/O ─────
|
||||
@@ -179,10 +179,10 @@ class MassImportServiceTest {
|
||||
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
|
||||
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");
|
||||
|
||||
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(documentService, never()).save(any());
|
||||
}
|
||||
@@ -204,7 +204,7 @@ class MassImportServiceTest {
|
||||
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||
assertThat(service.getStatus().skippedFiles())
|
||||
.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
|
||||
@@ -223,7 +223,7 @@ class MassImportServiceTest {
|
||||
assertThat(service.getStatus().skipped()).isEqualTo(1);
|
||||
assertThat(service.getStatus().skippedFiles())
|
||||
.extracting(MassImportService.SkippedFile::reason)
|
||||
.containsExactly(MassImportService.SkipReason.ALREADY_EXISTS);
|
||||
.containsExactly("ALREADY_EXISTS");
|
||||
}
|
||||
|
||||
// ─── importSingleDocument — create new document (metadata only) ───────────
|
||||
@@ -283,11 +283,11 @@ class MassImportServiceTest {
|
||||
doThrow(new RuntimeException("S3 error"))
|
||||
.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");
|
||||
|
||||
verify(documentService, never()).save(any());
|
||||
assertThat(result).isPresent().contains(MassImportService.SkipReason.S3_UPLOAD_FAILED);
|
||||
assertThat(result).isPresent().contains("S3_UPLOAD_FAILED");
|
||||
}
|
||||
|
||||
// ─── importSingleDocument — sender handling ───────────────────────────────
|
||||
@@ -438,110 +438,6 @@ class MassImportServiceTest {
|
||||
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 ────────────────────
|
||||
|
||||
@Test
|
||||
@@ -755,22 +651,7 @@ class MassImportServiceTest {
|
||||
assertThat(spyService.getStatus().skipped()).isEqualTo(1);
|
||||
assertThat(spyService.getStatus().skippedFiles())
|
||||
.extracting(MassImportService.SkippedFile::reason)
|
||||
.containsExactly(MassImportService.SkipReason.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);
|
||||
.containsExactly("FILE_READ_ERROR");
|
||||
}
|
||||
|
||||
// ─── readOds — XXE security regression ───────────────────────────────────
|
||||
|
||||
@@ -147,9 +147,6 @@ services:
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
GF_SERVER_ROOT_URL: ${GF_SERVER_ROOT_URL:-http://localhost:3003}
|
||||
# Read-only password for the grafana_reader PostgreSQL role; interpolated
|
||||
# into the provisioned PostgreSQL datasource (see datasources.yml).
|
||||
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
@@ -168,7 +165,6 @@ services:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- obs-net
|
||||
- archiv-net # PO Overview dashboard queries archive-db via the grafana_reader role
|
||||
|
||||
# --- Error Tracking: GlitchTip ---
|
||||
|
||||
|
||||
@@ -227,9 +227,6 @@ services:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
|
||||
SPRING_DATASOURCE_USERNAME: archiv
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
# Consumed by Flyway V68 via the ${grafanaDbPassword} placeholder to set
|
||||
# the read-only grafana_reader role's password.
|
||||
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
|
||||
# Application uses the bucket-scoped service account, not MinIO root.
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: archiv-app
|
||||
@@ -255,8 +252,6 @@ services:
|
||||
OTEL_METRICS_EXPORTER: none
|
||||
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
|
||||
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
|
||||
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||
LOGGING_STRUCTURED_FORMAT_CONSOLE: ecs
|
||||
networks:
|
||||
- archiv-net
|
||||
healthcheck:
|
||||
@@ -271,10 +266,6 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
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
|
||||
depends_on:
|
||||
backend:
|
||||
|
||||
@@ -163,9 +163,6 @@ services:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
|
||||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
# Consumed by Flyway V68 via the ${grafanaDbPassword} placeholder to set
|
||||
# the read-only grafana_reader role's password.
|
||||
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
|
||||
S3_ENDPOINT: http://minio:9000
|
||||
S3_ACCESS_KEY: ${MINIO_ROOT_USER}
|
||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
||||
|
||||
@@ -152,7 +152,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `PORT_GRAFANA` | Host port for the Grafana UI (bound to `127.0.0.1` only) | `3003` | — | — |
|
||||
| `POSTGRES_HOST` | PostgreSQL hostname for GlitchTip's db-init job and workers. Override when only the staging stack is running and `archive-db` is not resolvable by that name. | `archive-db` | — | — |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` user password | `changeme` | YES (prod) | YES |
|
||||
| `GRAFANA_DB_PASSWORD` | Password for the read-only `grafana_reader` PostgreSQL role used by the PO Overview dashboard (issue #651). Consumed by Flyway V68 and the Grafana PostgreSQL datasource. Generate with `openssl rand -hex 32`. | — | YES (prod) | YES |
|
||||
| `PORT_GLITCHTIP` | Host port for the GlitchTip UI (bound to `127.0.0.1` only) | `3002` | — | — |
|
||||
| `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (used in email links and CORS) | `http://localhost:3002` | YES (prod) | — |
|
||||
| `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` | — | YES | YES |
|
||||
@@ -257,7 +256,6 @@ git.raddatz.cloud A <server IP>
|
||||
| `MAIL_USERNAME` | release.yml | SMTP user |
|
||||
| `MAIL_PASSWORD` | release.yml | SMTP password |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong password |
|
||||
| `GRAFANA_DB_PASSWORD` | both | Read-only `grafana_reader` role password — `openssl rand -hex 32` |
|
||||
| `GLITCHTIP_SECRET_KEY` | both | Django secret key — `openssl rand -hex 32` |
|
||||
| `SENTRY_DSN` | both | GlitchTip project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
|
||||
| `VITE_SENTRY_DSN` | both | GlitchTip frontend project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
|
||||
@@ -359,7 +357,6 @@ Both files are passed explicitly via `--env-file` to the compose command, so the
|
||||
| Gitea secret | Notes |
|
||||
|---|---|
|
||||
| `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release |
|
||||
| `GRAFANA_DB_PASSWORD` | `openssl rand -hex 32`; shared by nightly and release — read-only DB role for the PO Overview dashboard |
|
||||
| `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release |
|
||||
| `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container |
|
||||
|
||||
|
||||
@@ -80,14 +80,6 @@ _See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._
|
||||
|
||||
**Sütterlin** — A specific standardized style of Kurrent taught in German schools from 1915 to 1941.
|
||||
|
||||
**Illegible word** — a word whose recognition confidence falls below the configured threshold; replaced with the literal token `[unleserlich]` in the rendered block text and counted in the `ocr_illegible_words_total` Prometheus counter.
|
||||
|
||||
**Models-ready gauge** — the `ocr_models_ready` Prometheus gauge, flipped from `0` to `1` once the FastAPI lifespan startup has finished loading the Kraken model and the spell-checker. Used both for the `/health` endpoint and as the supervised signal for the `ocr_models_ready < 1 for 2m` alert.
|
||||
|
||||
**Recognition model accuracy** — the accuracy reported by `ketos train` for the recognition (text-line) model, exposed as `ocr_model_accuracy{kind="recognition"}`. Sourced from `_parse_best_checkpoint` on the highest-scoring checkpoint after training.
|
||||
|
||||
**Segmentation model accuracy** — the accuracy reported by `ketos segtrain` for the baseline layout analysis (`blla`) model, exposed as `ocr_model_accuracy{kind="segmentation"}`. Distinct from recognition accuracy because the two models are trained and improved independently.
|
||||
|
||||
---
|
||||
|
||||
## Other Domain Terms
|
||||
|
||||
@@ -118,14 +118,11 @@ To find a trace for a specific request in staging/production, either increase th
|
||||
|
||||
## Metrics (Prometheus → Grafana)
|
||||
|
||||
Prometheus scrapes two targets every 15 s:
|
||||
Prometheus scrapes the backend management endpoint every 15 s:
|
||||
|
||||
```
|
||||
Target: backend:8081/actuator/prometheus
|
||||
Labels: job="spring-boot", application="Familienarchiv"
|
||||
|
||||
Target: ocr:8000/metrics
|
||||
Labels: job="ocr-service"
|
||||
```
|
||||
|
||||
All Spring Boot metrics carry the `application="Familienarchiv"` tag, which is how the Grafana Spring Boot Observability dashboard (ID 17175) filters to this service.
|
||||
@@ -149,70 +146,6 @@ jvm_memory_used_bytes{area="heap", application="Familienarchiv"}
|
||||
hikaricp_connections_active
|
||||
```
|
||||
|
||||
### OCR-service custom metrics
|
||||
|
||||
Exposed at `ocr:8000/metrics` by `prometheus-fastapi-instrumentator`. The
|
||||
`http_*` metrics describe the FastAPI request layer; the `ocr_*` series are
|
||||
domain-specific. **Never label these with PII or document content** — labels
|
||||
have unbounded cardinality risk and are visible to anyone with Grafana access.
|
||||
|
||||
| Metric | Type | Labels | Unit | What it tracks |
|
||||
|---|---|---|---|---|
|
||||
| `ocr_jobs_total` | Counter | `engine` (`surya`/`kraken`), `script_type` | jobs | OCR jobs that started after a successful PDF download |
|
||||
| `ocr_pages_total` | Counter | `engine` | pages | Successfully OCR'd pages in the streaming generator |
|
||||
| `ocr_skipped_pages_total` | Counter | — | pages | Pages skipped because the engine raised on them |
|
||||
| `ocr_words_total` | Counter | — | words | Recognized words summed across every block |
|
||||
| `ocr_illegible_words_total` | Counter | — | words | Words below the confidence threshold (rendered as `[unleserlich]`) |
|
||||
| `ocr_processing_seconds` | Histogram | `engine` | seconds | Per-page (stream) or per-document (`/ocr`) engine time, excluding preprocessing |
|
||||
| `ocr_training_runs_total` | Counter | `kind` (`recognition`/`segmentation`), `outcome` (`success`/`error`) | runs | Completed training runs |
|
||||
| `ocr_model_accuracy` | Gauge | `kind` | ratio (0–1) | Latest accuracy reported by a successful training run |
|
||||
| `ocr_models_ready` | Gauge | — | 0\|1 | 1 once the lifespan startup has finished loading models |
|
||||
|
||||
Canonical example queries (the same ones referenced in issue #652):
|
||||
|
||||
```promql
|
||||
# OCR throughput by engine
|
||||
sum by (engine) (rate(ocr_pages_total[5m]))
|
||||
|
||||
# Share of words rendered as [unleserlich]
|
||||
sum(rate(ocr_illegible_words_total[5m]))
|
||||
/ sum(rate(ocr_words_total[5m]))
|
||||
|
||||
# p95 page processing time per engine
|
||||
histogram_quantile(0.95, sum by (engine, le) (
|
||||
rate(ocr_processing_seconds_bucket[5m])
|
||||
))
|
||||
|
||||
# Training error rate
|
||||
sum(rate(ocr_training_runs_total{outcome="error"}[1h]))
|
||||
/ sum(rate(ocr_training_runs_total[1h]))
|
||||
|
||||
# Latest recognition vs segmentation accuracy
|
||||
ocr_model_accuracy
|
||||
```
|
||||
|
||||
### Internal-only endpoints
|
||||
|
||||
`/metrics` is exposed by the OCR service over plain HTTP without
|
||||
authentication. The container is reachable only on the internal Docker
|
||||
network — Caddy never proxies to it directly. If the service is ever
|
||||
exposed (e.g. a `ports:` mapping is added), block the endpoint at the
|
||||
reverse proxy:
|
||||
|
||||
```caddy
|
||||
ocr.example.com {
|
||||
@internal_only path /metrics /health
|
||||
respond @internal_only 404
|
||||
reverse_proxy ocr:8000
|
||||
}
|
||||
```
|
||||
|
||||
The `MetricsPathFilter` in `ocr-service/main.py` suppresses uvicorn's
|
||||
**stdout** access log lines for `/metrics` and `/health` so the container
|
||||
console stays focused on real OCR traffic. Promtail/Loki still receive
|
||||
access lines from any other source. Treat the filter as console
|
||||
noise-control, not an audit-suppression mechanism.
|
||||
|
||||
## Errors (GlitchTip)
|
||||
|
||||
GlitchTip receives errors from both the backend (via Sentry Java SDK) and the frontend (via Sentry JavaScript SDK). It groups events by fingerprint, tracks first/last seen times, and links to the release that introduced the error.
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# ADR-023: Prometheus Instrumentator and Metrics Registry Injection
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Until issue #652 the OCR service exposed no `/metrics` endpoint. The
|
||||
observability stack already scrapes the Spring Boot backend's actuator
|
||||
endpoint, but it had nothing to scrape on the Python side. Without HTTP-
|
||||
and domain-level metrics from `ocr-service` we cannot answer questions
|
||||
like "what is the share of words rendered as `[unleserlich]`" or
|
||||
"is the training error rate above its budget" from Grafana.
|
||||
|
||||
Two implementation requirements influenced the design:
|
||||
|
||||
1. **Counter / gauge isolation in tests.** `prometheus_client` collectors
|
||||
are module-level singletons keyed by name on the global `REGISTRY`.
|
||||
Re-importing or naively re-instantiating them raises a duplicated-
|
||||
collector error and cross-test state leaks (a `.inc()` in test A is
|
||||
still readable by test B). A test harness needs a way to swap the
|
||||
active container for a fresh per-test instance.
|
||||
|
||||
2. **Minimal blast radius on the request path.** We did not want to
|
||||
hand-instrument every endpoint with FastAPI middleware. The
|
||||
`prometheus-fastapi-instrumentator` library already provides
|
||||
`http_requests_total`, `http_request_duration_seconds`, and the
|
||||
`/metrics` exposition route, all idiomatic Prometheus names.
|
||||
|
||||
## Decision
|
||||
|
||||
- Add `prometheus-fastapi-instrumentator==7.0.0` and pin its transitive
|
||||
dependency `prometheus-client==0.25.0` explicitly in
|
||||
`ocr-service/requirements.txt`.
|
||||
- Mount the instrumentator once at module load:
|
||||
`Instrumentator(excluded_handlers=["/health", "/metrics"]).instrument(app).expose(app)`.
|
||||
This adds `/metrics` and an HTTP-level dashboard surface without
|
||||
changing any endpoint code.
|
||||
- Define every domain metric (`ocr_jobs_total`, `ocr_pages_total`,
|
||||
`ocr_processing_seconds`, …) inside a `build_metrics(registry)`
|
||||
factory in `ocr-service/metrics.py` that returns a frozen `OcrMetrics`
|
||||
dataclass. Production code binds the container to the default
|
||||
`REGISTRY` once: `metrics: OcrMetrics = build_metrics(REGISTRY)`.
|
||||
- Tests use a `fresh_metrics` fixture that builds a new
|
||||
`CollectorRegistry()` per test and monkeypatches `main.metrics` with
|
||||
a container bound to it. The endpoint code keeps reading
|
||||
`metrics.<name>` without knowing whether it is talking to the global
|
||||
registry or a per-test one.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- One reusable factory captures the metric definitions; future metrics
|
||||
go in one place.
|
||||
- Tests run with full counter isolation. Cross-test state leakage is
|
||||
impossible because each test sees its own dataclass instance.
|
||||
- The instrumentator gives us `http_*` metrics for free, including a
|
||||
Grafana-ready histogram that pairs with the Spring Boot one.
|
||||
|
||||
**Negative**
|
||||
|
||||
- One extra level of indirection: any test that asserts on metric
|
||||
values must remember to monkeypatch `main.metrics`, not the registry
|
||||
directly. Rebinding through the registry is harmless but useless —
|
||||
the dataclass holds references to the original collectors.
|
||||
- `prometheus-client` is now pinned. Upgrading it requires an explicit
|
||||
bump and re-checking the instrumentator's compatibility range.
|
||||
- `/metrics` is exposed unauthenticated and relies on the Docker
|
||||
internal network for confidentiality. See
|
||||
[docs/OBSERVABILITY.md §Internal-only endpoints](../OBSERVABILITY.md)
|
||||
for the Caddy snippet that must be added if the service ever gets a
|
||||
host-side port mapping.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Hand-roll the `/metrics` endpoint.** Rejected: would have meant
|
||||
duplicating what `prometheus-fastapi-instrumentator` ships, plus
|
||||
middleware for the HTTP histograms.
|
||||
- **Skip the factory; pass `registry` as a function argument
|
||||
everywhere.** Rejected: clutters every endpoint signature and breaks
|
||||
the symmetry with the Spring Boot side, which also relies on a
|
||||
process-global Micrometer registry.
|
||||
- **Use a `pytest` autouse fixture that resets `REGISTRY` between
|
||||
tests.** Rejected: `prometheus_client` does not expose a clean
|
||||
"unregister all" hook, and we would be relying on private APIs.
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [#652](https://git.raddatz.cloud/marcel/familienarchiv/issues/652)
|
||||
- Library: <https://github.com/trallnag/prometheus-fastapi-instrumentator>
|
||||
- Code: `ocr-service/metrics.py`, `ocr-service/main.py`,
|
||||
`ocr-service/test_metrics.py`
|
||||
@@ -43,12 +43,9 @@ Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
|
||||
Rel(mc, storage, "Bootstraps bucket + service account on startup", "MinIO Client CLI")
|
||||
Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API")
|
||||
Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)")
|
||||
Rel(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus")
|
||||
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
|
||||
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
|
||||
Rel(grafana, loki, "Queries logs", "HTTP 3100")
|
||||
Rel(grafana, tempo, "Queries traces", "HTTP 3200")
|
||||
Rel(grafana, db, "Read-only dashboard queries via grafana_reader role", "PostgreSQL / archiv-net")
|
||||
Rel(glitchtip, db, "Stores error events in glitchtip DB", "PostgreSQL / archiv-net")
|
||||
Rel(obs_glitchtip_worker, obs_redis, "Processes Celery tasks", "Redis / obs-net")
|
||||
|
||||
|
||||
@@ -16,10 +16,6 @@ CMD ["npm", "run", "dev"]
|
||||
# Compiles the SvelteKit Node-adapter output to /app/build.
|
||||
FROM node:20.19.0-alpine3.21 AS build
|
||||
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 ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
@@ -526,7 +526,6 @@
|
||||
"notification_filter_unread": "Ungelesen",
|
||||
"notification_filter_mention": "Erwähnung",
|
||||
"notification_filter_reply": "Antwort",
|
||||
"notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
|
||||
"notification_load_more": "Ältere laden",
|
||||
"notification_empty_history": "Keine Benachrichtigungen",
|
||||
@@ -638,9 +637,6 @@
|
||||
"transcription_block_review": "Als geprüft markieren",
|
||||
"transcription_block_unreview": "Markierung aufheben",
|
||||
"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_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",
|
||||
|
||||
@@ -526,7 +526,6 @@
|
||||
"notification_filter_unread": "Unread",
|
||||
"notification_filter_mention": "Mention",
|
||||
"notification_filter_reply": "Reply",
|
||||
"notification_error_generic": "Action failed. Please try again.",
|
||||
"notification_mark_all_read_aria": "Mark all notifications as read",
|
||||
"notification_load_more": "Load older",
|
||||
"notification_empty_history": "No notifications",
|
||||
@@ -638,9 +637,6 @@
|
||||
"transcription_block_review": "Mark as reviewed",
|
||||
"transcription_block_unreview": "Unmark as 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_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",
|
||||
|
||||
@@ -526,7 +526,6 @@
|
||||
"notification_filter_unread": "No leídas",
|
||||
"notification_filter_mention": "Mención",
|
||||
"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_load_more": "Cargar anteriores",
|
||||
"notification_empty_history": "Sin notificaciones",
|
||||
@@ -638,9 +637,6 @@
|
||||
"transcription_block_review": "Marcar como revisado",
|
||||
"transcription_block_unreview": "Desmarcar como revisado",
|
||||
"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_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",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTime } from '$lib/shared/utils/time';
|
||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||
@@ -7,13 +6,11 @@ import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
||||
|
||||
interface Props {
|
||||
unread: NotificationItem[];
|
||||
optimisticMarkRead: (id: string) => void;
|
||||
optimisticMarkAllRead: () => void;
|
||||
onMarkRead: (n: NotificationItem) => void;
|
||||
onMarkAllRead: () => void;
|
||||
}
|
||||
|
||||
const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props();
|
||||
|
||||
let errorMessage: string | null = $state(null);
|
||||
const { unread, onMarkRead, onMarkAllRead }: Props = $props();
|
||||
|
||||
function verb(type: NotificationItem['type'], actor: string): string {
|
||||
return type === 'REPLY'
|
||||
@@ -27,9 +24,6 @@ function href(n: NotificationItem): string {
|
||||
</script>
|
||||
|
||||
<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}
|
||||
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<svg
|
||||
@@ -72,28 +66,14 @@ function href(n: NotificationItem): string {
|
||||
{m.chronik_for_you_count({ count: unread.length })}
|
||||
</span>
|
||||
</div>
|
||||
<form
|
||||
action="/aktivitaeten?/mark-all-read"
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
errorMessage = null;
|
||||
optimisticMarkAllRead();
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' || result.type === 'error') {
|
||||
errorMessage = m.notification_error_generic();
|
||||
await update({ reset: false, invalidateAll: false });
|
||||
}
|
||||
};
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chronik-mark-all-read"
|
||||
onclick={onMarkAllRead}
|
||||
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
{m.chronik_mark_all_read()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul role="list" class="flex flex-col gap-2">
|
||||
@@ -109,7 +89,7 @@ function href(n: NotificationItem): string {
|
||||
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"
|
||||
>
|
||||
{n.type === 'MENTION' ? '@' : '↩'}
|
||||
{n.type === 'MENTION' ? '@' : '\u21A9'}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-sans text-sm leading-snug text-ink">
|
||||
@@ -120,40 +100,25 @@ function href(n: NotificationItem): string {
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<form
|
||||
action="/aktivitaeten?/dismiss-notification"
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
errorMessage = null;
|
||||
optimisticMarkRead(n.id);
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'failure' || result.type === 'error') {
|
||||
errorMessage = m.notification_error_generic();
|
||||
await update({ reset: false, invalidateAll: false });
|
||||
}
|
||||
};
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chronik-fuerdich-dismiss"
|
||||
aria-label={m.chronik_mark_read_aria()}
|
||||
onclick={() => onMarkRead(n)}
|
||||
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"
|
||||
>
|
||||
<input type="hidden" name="notificationId" value={n.id} />
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="chronik-fuerdich-dismiss"
|
||||
aria-label={m.chronik_mark_read_aria()}
|
||||
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"
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
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>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -5,36 +5,7 @@ import { page, userEvent } from 'vitest/browser';
|
||||
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||
|
||||
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
||||
|
||||
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';
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
function notif(partial: Partial<NotificationItem>): NotificationItem {
|
||||
return {
|
||||
@@ -55,8 +26,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
it('renders inbox-zero state when there are no unread items', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [],
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
|
||||
expect(zero).not.toBeNull();
|
||||
@@ -66,8 +37,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
it('links to the archived mentions in the inbox-zero state', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [],
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
|
||||
expect(link).not.toBeNull();
|
||||
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
it('renders the count badge with correct total when unread exists', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'a' }), notif({ id: 'b' })],
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
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 () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'a' })],
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
// Wait for render
|
||||
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 () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [],
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
|
||||
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 () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'a' })],
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => {
|
||||
const optimisticMarkAllRead = vi.fn();
|
||||
it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
|
||||
const onMarkAllRead = vi.fn();
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'a' })],
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead
|
||||
});
|
||||
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 () => {
|
||||
const optimisticMarkRead = vi.fn();
|
||||
it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => {
|
||||
const onMarkRead = vi.fn();
|
||||
const n = notif({ id: 'xyz' });
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [n],
|
||||
optimisticMarkRead,
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
onMarkRead,
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
const dismiss = document.querySelector(
|
||||
'[data-testid="chronik-fuerdich-dismiss"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(dismiss).not.toBeNull();
|
||||
dismiss?.click();
|
||||
expect(optimisticMarkRead).toHaveBeenCalledTimes(1);
|
||||
expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz');
|
||||
expect(onMarkRead).toHaveBeenCalledTimes(1);
|
||||
expect(onMarkRead.mock.calls[0][0]).toEqual(n);
|
||||
});
|
||||
|
||||
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
|
||||
@@ -153,8 +124,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
annotationId: 'annot-9'
|
||||
})
|
||||
],
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
const link = document.querySelector(
|
||||
'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 () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'x' })],
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn()
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
|
||||
expect(dismiss).not.toBeNull();
|
||||
@@ -174,22 +145,4 @@ describe('ChronikFuerDichBox', () => {
|
||||
// Prevents the senior-audience tap-drag bug flagged by Leonie.
|
||||
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 type { NotificationItem } from '$lib/notification/notifications';
|
||||
|
||||
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
|
||||
|
||||
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';
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||
id: 'n-1',
|
||||
@@ -51,7 +22,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
|
||||
describe('ChronikFuerDichBox', () => {
|
||||
it('renders the inbox-zero state when there are no unread', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} }
|
||||
props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
|
||||
@@ -63,8 +34,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {}
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {}
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -91,8 +62,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ actorName: 'Bertha' })],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {}
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -105,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {}
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -115,11 +86,11 @@ describe('ChronikFuerDichBox', () => {
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => {
|
||||
const optimisticMarkRead = vi.fn();
|
||||
it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
|
||||
const onMarkRead = vi.fn();
|
||||
const item = mention({ id: 'n-7' });
|
||||
render(ChronikFuerDichBox, {
|
||||
props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} }
|
||||
props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
|
||||
});
|
||||
|
||||
const dismiss = document.querySelector(
|
||||
@@ -127,55 +98,35 @@ describe('ChronikFuerDichBox', () => {
|
||||
) as HTMLElement;
|
||||
dismiss.click();
|
||||
|
||||
expect(optimisticMarkRead).toHaveBeenCalledWith('n-7');
|
||||
expect(onMarkRead).toHaveBeenCalledWith(item);
|
||||
});
|
||||
|
||||
it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => {
|
||||
const optimisticMarkAllRead = vi.fn();
|
||||
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
|
||||
const onMarkAllRead = vi.fn();
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention()],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead
|
||||
}
|
||||
});
|
||||
|
||||
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
|
||||
btn.click();
|
||||
|
||||
expect(optimisticMarkAllRead).toHaveBeenCalledOnce();
|
||||
expect(onMarkAllRead).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('builds a deep-link href to the comment for each notification', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {}
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
|
||||
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 type { Tag } from '$lib/tag/TagInput.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { withCsrf } from '$lib/shared/cookies';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
@@ -184,10 +183,7 @@ async function saveUpload() {
|
||||
// FormData with per-chunk progress. Session cookie is sent automatically
|
||||
// by the browser for same-origin requests.
|
||||
try {
|
||||
const res = await fetch(
|
||||
'/api/documents/quick-upload',
|
||||
withCsrf({ method: 'POST', body: formData })
|
||||
);
|
||||
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
||||
const body = await res.json().catch(() => ({ errors: [] }));
|
||||
const errorFilenames = new Set<string>(
|
||||
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
||||
|
||||
@@ -6,7 +6,6 @@ import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyStat
|
||||
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
|
||||
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
|
||||
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
|
||||
import { withCsrf } from '$lib/shared/cookies';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
@@ -50,7 +49,6 @@ let activeBlockId: string | null = $state(null);
|
||||
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
||||
let listEl: HTMLElement | null = $state(null);
|
||||
let markingAllReviewed = $state(false);
|
||||
let markAllError = $state<string | null>(null);
|
||||
|
||||
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
const hasBlocks = $derived(blocks.length > 0);
|
||||
@@ -69,11 +67,8 @@ $effect(() => {
|
||||
async function handleMarkAllReviewed() {
|
||||
if (!onMarkAllReviewed) return;
|
||||
markingAllReviewed = true;
|
||||
markAllError = null;
|
||||
try {
|
||||
await onMarkAllReviewed();
|
||||
} catch {
|
||||
markAllError = m.transcription_mark_all_reviewed_error();
|
||||
} finally {
|
||||
markingAllReviewed = false;
|
||||
}
|
||||
@@ -114,14 +109,11 @@ function handleDelete(blockId: string) {
|
||||
|
||||
async function reorder(newOrder: string[]) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/documents/${documentId}/transcription-blocks/reorder`,
|
||||
withCsrf({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ blockIds: newOrder })
|
||||
})
|
||||
);
|
||||
const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ blockIds: newOrder })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated = await res.json();
|
||||
for (const b of updated) {
|
||||
@@ -177,7 +169,7 @@ async function handleLabelToggle(label: string) {
|
||||
<button
|
||||
onclick={handleMarkAllReviewed}
|
||||
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"
|
||||
>
|
||||
{#if markingAllReviewed}
|
||||
@@ -215,7 +207,7 @@ async function handleLabelToggle(label: string) {
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
{m.transcription_mark_all_reviewed()}
|
||||
Alle als fertig markieren
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -225,31 +217,6 @@ async function handleLabelToggle(label: string) {
|
||||
style="width: {reviewProgress}%"
|
||||
></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 class="p-4">
|
||||
<!-- 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 TranscriptionEditView from './TranscriptionEditView.svelte';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -313,14 +312,14 @@ describe('TranscriptionEditView — mark all reviewed', () => {
|
||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
|
||||
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -330,7 +329,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
|
||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -344,7 +343,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
|
||||
// userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick
|
||||
// handlers when a TipTap editor is mounted in the same component tree.
|
||||
const btn = (await page
|
||||
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
|
||||
.getByRole('button', { name: /Alle als fertig markieren/ })
|
||||
.element()) as HTMLButtonElement;
|
||||
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
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
|
||||
const btnEl = (await page
|
||||
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
|
||||
.getByRole('button', { name: /Alle als fertig markieren/ })
|
||||
.element()) as HTMLButtonElement;
|
||||
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.toBeDisabled();
|
||||
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 type { PersonMention } from '$lib/shared/types';
|
||||
import { withCsrf } from '$lib/shared/cookies';
|
||||
|
||||
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||
|
||||
@@ -117,15 +116,12 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
||||
for (const [blockId, text] of pendingTexts) {
|
||||
const mentions = pendingMentions.get(blockId) ?? [];
|
||||
clearDebounce(blockId);
|
||||
void fetch(
|
||||
`/api/documents/${documentId}/transcription-blocks/${blockId}`,
|
||||
withCsrf({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, mentionedPersons: mentions }),
|
||||
keepalive: true
|
||||
})
|
||||
);
|
||||
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, mentionedPersons: mentions }),
|
||||
keepalive: true
|
||||
});
|
||||
pendingTexts.delete(blockId);
|
||||
pendingMentions.delete(blockId);
|
||||
}
|
||||
|
||||
@@ -259,15 +259,12 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
|
||||
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 u = url.toString();
|
||||
const method = init?.method ?? 'GET';
|
||||
if (u.includes('/review-all') && method === 'PUT') {
|
||||
return new Response(JSON.stringify({ code: 'INTERNAL_ERROR' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
return new Response('', { status: 500 });
|
||||
}
|
||||
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
|
||||
status: 200,
|
||||
@@ -277,26 +274,7 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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');
|
||||
await ctrl.markAllReviewed();
|
||||
expect(ctrl.blocks[0].reviewed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
lastEditedAt's $derived are scope-local to one computation; they're never
|
||||
stored on $state. */
|
||||
import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types';
|
||||
import { makeCsrfFetch } from '$lib/shared/cookies';
|
||||
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
|
||||
import { BlockConflictResolvedError } from './blockConflictMerge';
|
||||
|
||||
@@ -42,7 +41,7 @@ export function createTranscriptionBlocks(
|
||||
options: TranscriptionBlocksOptions
|
||||
): TranscriptionBlocksController {
|
||||
const { documentId } = options;
|
||||
const fetchImpl = makeCsrfFetch(options.fetchImpl ?? fetch);
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
|
||||
let blocks = $state<TranscriptionBlockData[]>([]);
|
||||
let annotationReloadKey = $state(0);
|
||||
@@ -120,11 +119,7 @@ export function createTranscriptionBlocks(
|
||||
const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
if (!res.ok) {
|
||||
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');
|
||||
}
|
||||
if (!res.ok) return;
|
||||
const updated = (await res.json()) as { id: string; reviewed: boolean }[];
|
||||
for (const b of updated) {
|
||||
const existing = blocks.find((x) => x.id === b.id);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
import { notificationStore } from '$lib/notification/notifications.svelte';
|
||||
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
||||
import NotificationDropdown from './NotificationDropdown.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
@@ -28,6 +30,17 @@ function closeDropdown() {
|
||||
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) {
|
||||
if (event.key === 'Escape' && open) {
|
||||
event.stopPropagation();
|
||||
@@ -100,8 +113,8 @@ onDestroy(() => {
|
||||
{#if open}
|
||||
<NotificationDropdown
|
||||
notifications={stream.notifications}
|
||||
optimisticMarkRead={stream.optimisticMarkRead}
|
||||
optimisticMarkAllRead={stream.optimisticMarkAllRead}
|
||||
onMarkRead={handleMarkRead}
|
||||
onMarkAllRead={stream.markAllRead}
|
||||
onClose={closeDropdown}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -3,18 +3,10 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import type { NotificationItem } from '$lib/notification/notifications';
|
||||
import NotificationBell from './NotificationBell.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
|
||||
vi.mock('$app/forms', () => ({
|
||||
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 gotoMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
|
||||
|
||||
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
|
||||
|
||||
vi.mock('$lib/notification/notifications.svelte', () => ({
|
||||
@@ -25,17 +17,18 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
|
||||
get unreadCount() {
|
||||
return mockNotificationList.value.length;
|
||||
},
|
||||
optimisticMarkRead: vi.fn(),
|
||||
optimisticMarkAllRead: vi.fn(),
|
||||
markRead: mockMarkRead,
|
||||
fetchNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
init: vi.fn(),
|
||||
destroy: vi.fn()
|
||||
destroy: vi.fn(),
|
||||
markAllRead: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
gotoMock.mockClear();
|
||||
mockMarkRead.mockClear();
|
||||
mockNotificationList.value = [];
|
||||
});
|
||||
|
||||
@@ -52,6 +45,16 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
|
||||
...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', () => {
|
||||
it('bell button has cursor-pointer class', async () => {
|
||||
render(NotificationBell);
|
||||
@@ -79,3 +82,29 @@ describe('NotificationBell — cursor and tooltip', () => {
|
||||
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">
|
||||
import { goto } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { relativeTime } from '$lib/shared/utils/time';
|
||||
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
|
||||
import type { NotificationItem } from '$lib/notification/notifications.svelte';
|
||||
|
||||
type Props = {
|
||||
notifications: NotificationItem[];
|
||||
optimisticMarkRead: (id: string) => void;
|
||||
optimisticMarkAllRead: () => void;
|
||||
onMarkRead: (notification: NotificationItem) => void;
|
||||
onMarkAllRead: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props();
|
||||
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
|
||||
|
||||
function handleViewAll() {
|
||||
onClose(); // close first — avoids stale dropdown during navigation transition
|
||||
@@ -35,35 +31,16 @@ function handleViewAll() {
|
||||
{m.notification_bell_label()}
|
||||
</span>
|
||||
{#if notifications.length > 0}
|
||||
<form
|
||||
action="/aktivitaeten?/mark-all-read"
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
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
|
||||
type="button"
|
||||
onclick={onMarkAllRead}
|
||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.notification_mark_all_read()}
|
||||
</button>
|
||||
</form>
|
||||
{m.notification_mark_all_read()}
|
||||
</button>
|
||||
{/if}
|
||||
</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 -->
|
||||
{#if notifications.length === 0}
|
||||
<!-- Empty state -->
|
||||
@@ -89,93 +66,67 @@ function handleViewAll() {
|
||||
<ul role="list" class="max-h-[24rem] overflow-y-auto">
|
||||
{#each notifications as notification (notification.id)}
|
||||
<li>
|
||||
<form
|
||||
action="/aktivitaeten?/dismiss-notification"
|
||||
method="POST"
|
||||
class="contents"
|
||||
use:enhance={() => {
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onMarkRead(notification)}
|
||||
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
|
||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||
>
|
||||
<input type="hidden" name="notificationId" value={notification.id} />
|
||||
<button
|
||||
type="submit"
|
||||
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
|
||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||
>
|
||||
<!-- Type icon -->
|
||||
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||
{#if notification.type === 'REPLY'}
|
||||
<!-- Reply icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Mention icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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>
|
||||
<!-- Type icon -->
|
||||
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
|
||||
{#if notification.type === 'REPLY'}
|
||||
<!-- Reply icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Mention icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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}
|
||||
</button>
|
||||
</form>
|
||||
</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}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -6,38 +6,9 @@ import NotificationDropdown from './NotificationDropdown.svelte';
|
||||
|
||||
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(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
mockFormResult.type = 'success'; // reset to default after each test
|
||||
});
|
||||
|
||||
const makeNotification = (overrides: Record<string, unknown> = {}) => ({
|
||||
@@ -58,8 +29,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -71,8 +42,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -84,8 +55,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -99,8 +70,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [makeNotification()],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -112,8 +83,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -127,8 +98,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -145,8 +116,8 @@ describe('NotificationDropdown', () => {
|
||||
makeNotification({ id: 'n1', read: false }),
|
||||
makeNotification({ id: 'n2', read: true })
|
||||
],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -155,100 +126,37 @@ describe('NotificationDropdown', () => {
|
||||
expect(unreadDots.length).toBe(1);
|
||||
});
|
||||
|
||||
it('each notification row is wrapped in a form posting to the dismiss action', async () => {
|
||||
render(NotificationDropdown, {
|
||||
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();
|
||||
it('calls onMarkRead with the notification when an item is clicked', async () => {
|
||||
const onMarkRead = vi.fn();
|
||||
const n = makeNotification({ id: 'n42', actorName: 'Anna' });
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [n],
|
||||
optimisticMarkRead,
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead,
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
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, {
|
||||
props: {
|
||||
notifications: [makeNotification()],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
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,
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead,
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /alle gelesen/i }).click();
|
||||
|
||||
expect(optimisticMarkAllRead).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();
|
||||
expect(onMarkAllRead).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onClose when the view-all button is clicked', async () => {
|
||||
@@ -256,8 +164,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose
|
||||
}
|
||||
});
|
||||
@@ -271,8 +179,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -285,15 +193,12 @@ describe('NotificationDropdown', () => {
|
||||
it('calls onClose before navigating to /aktivitaeten', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const onClose = vi.fn(() => callOrder.push('close'));
|
||||
vi.mocked(goto).mockImplementation(() => {
|
||||
callOrder.push('goto');
|
||||
return Promise.resolve();
|
||||
});
|
||||
vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose
|
||||
}
|
||||
});
|
||||
@@ -307,8 +212,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -320,8 +225,8 @@ describe('NotificationDropdown', () => {
|
||||
render(NotificationDropdown, {
|
||||
props: {
|
||||
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
@@ -337,78 +242,14 @@ describe('NotificationDropdown', () => {
|
||||
makeNotification({ id: 'n1', actorName: 'First' }),
|
||||
makeNotification({ id: 'n2', actorName: 'Second' })
|
||||
],
|
||||
optimisticMarkRead: () => {},
|
||||
optimisticMarkAllRead: () => {},
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {},
|
||||
onClose: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]');
|
||||
expect(forms.length).toBe(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');
|
||||
const items = document.querySelectorAll('button[type="button"]');
|
||||
// At least 2 items + mark-all button
|
||||
expect(items.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,46 +108,12 @@ describe('notificationStore (singleton)', () => {
|
||||
expect(notificationStore.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
|
||||
notificationStore.init();
|
||||
const notification = makeNotification({ id: 'sse-1', read: false });
|
||||
lastEventSource!.simulate('notification', JSON.stringify(notification));
|
||||
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
|
||||
it('markAllRead resets unreadCount', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
await notificationStore.markAllRead();
|
||||
|
||||
notificationStore.optimisticMarkRead('sse-1');
|
||||
|
||||
expect(notificationStore.notifications[0].read).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
|
||||
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 {
|
||||
const notification = notifications.find((n) => n.id === id);
|
||||
if (notification && !notification.read) {
|
||||
notification.read = true;
|
||||
unreadCount = Math.max(0, unreadCount - 1);
|
||||
async function markRead(notification: NotificationItem): Promise<void> {
|
||||
if (!notification.read) {
|
||||
try {
|
||||
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
|
||||
notification.read = true;
|
||||
unreadCount = Math.max(0, unreadCount - 1);
|
||||
} catch (e) {
|
||||
console.error('Failed to mark notification as read', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function optimisticMarkAllRead(): void {
|
||||
for (const n of notifications) {
|
||||
n.read = true;
|
||||
async function markAllRead(): Promise<void> {
|
||||
try {
|
||||
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 {
|
||||
@@ -114,8 +123,8 @@ export const notificationStore = {
|
||||
},
|
||||
fetchNotifications,
|
||||
fetchUnreadCount,
|
||||
optimisticMarkRead,
|
||||
optimisticMarkAllRead,
|
||||
markRead,
|
||||
markAllRead,
|
||||
init,
|
||||
destroy
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import TrainingHistory from './TrainingHistory.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { TrainingRun } from '$lib/ocr/training.js';
|
||||
import { withCsrf } from '$lib/shared/cookies';
|
||||
|
||||
interface TrainingInfo {
|
||||
availableBlocks?: number;
|
||||
@@ -34,7 +33,7 @@ async function startTraining() {
|
||||
successMessage = null;
|
||||
errorMessage = null;
|
||||
try {
|
||||
const res = await fetch('/api/ocr/train', withCsrf({ method: 'POST' }));
|
||||
const res = await fetch('/api/ocr/train', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
successMessage = m.training_success();
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import TrainingHistory from './TrainingHistory.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { TrainingRun } from '$lib/ocr/training.js';
|
||||
import { withCsrf } from '$lib/shared/cookies';
|
||||
|
||||
interface TrainingInfo {
|
||||
availableSegBlocks?: number;
|
||||
@@ -28,7 +27,7 @@ async function startTraining() {
|
||||
training = true;
|
||||
successMessage = null;
|
||||
try {
|
||||
const res = await fetch('/api/ocr/segtrain', withCsrf({ method: 'POST' }));
|
||||
const res = await fetch('/api/ocr/segtrain', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
successMessage = m.training_success();
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
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 type { components } from '$lib/generated/api';
|
||||
|
||||
@@ -34,16 +34,16 @@ export async function load({ fetch, locals }) {
|
||||
]);
|
||||
|
||||
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) {
|
||||
throw error(
|
||||
groupsResult.response.status,
|
||||
getErrorMessage(extractErrorCode(groupsResult.error))
|
||||
);
|
||||
const code = (groupsResult.error as unknown as { code?: string })?.code;
|
||||
throw error(groupsResult.response.status, getErrorMessage(code));
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
@@ -24,9 +24,8 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
@@ -39,9 +38,8 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
throw redirect(303, '/admin/groups');
|
||||
|
||||
@@ -7,8 +7,7 @@ const mockApi = {
|
||||
};
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: () => mockApi,
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
createApiClient: () => mockApi
|
||||
}));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { load } from './+layout.server';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -16,9 +16,8 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
throw redirect(303, '/admin/groups');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { Actions, PageServerLoad } from './$types';
|
||||
import type { components } from '$lib/generated/api';
|
||||
@@ -25,7 +25,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
let invites: InviteListItem[] = [];
|
||||
let loadError: string | null = null;
|
||||
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 {
|
||||
invites = (invitesResult.data ?? []) as InviteListItem[];
|
||||
}
|
||||
@@ -33,7 +34,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
let groups: UserGroup[] = [];
|
||||
let groupsLoadError: string | null = null;
|
||||
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 {
|
||||
const raw = groupsResult.data ?? [];
|
||||
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -60,9 +62,8 @@ export const actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
createError: extractErrorCode(result.error) ?? 'INTERNAL_ERROR'
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' });
|
||||
}
|
||||
|
||||
return { created: result.data! as InviteListItem };
|
||||
@@ -77,9 +78,8 @@ export const actions = {
|
||||
const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } });
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
revokeError: extractErrorCode(result.error) ?? 'INTERNAL_ERROR'
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { revokeError: code ?? 'INTERNAL_ERROR' });
|
||||
}
|
||||
|
||||
return { revoked: id };
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { load } from './+layout.server';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
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');
|
||||
|
||||
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! };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
@@ -10,7 +10,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
@@ -3,10 +3,7 @@ import { load } from './+page.server';
|
||||
|
||||
const mockApi = { GET: vi.fn() };
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: () => mockApi,
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
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');
|
||||
|
||||
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! };
|
||||
|
||||
@@ -3,10 +3,7 @@ import { load } from './+page.server';
|
||||
|
||||
const mockApi = { GET: vi.fn() };
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: () => mockApi,
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@ import { load } from './+page.server';
|
||||
|
||||
const mockApi = { GET: vi.fn() };
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: () => mockApi,
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, parent, url }) => {
|
||||
@@ -25,9 +25,8 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
@@ -44,9 +43,8 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
throw redirect(303, `/admin/tags/${result.data!.id}?merged=1`);
|
||||
@@ -67,9 +65,8 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
throw redirect(303, '/admin/tags');
|
||||
|
||||
@@ -8,8 +8,7 @@ const mockApi = {
|
||||
};
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: () => mockApi,
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
createApiClient: () => mockApi
|
||||
}));
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { load } from './+layout.server';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
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 type { components } from '$lib/generated/api';
|
||||
|
||||
@@ -55,9 +55,8 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
@@ -70,9 +69,8 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
throw redirect(303, '/admin/users');
|
||||
|
||||
@@ -4,10 +4,7 @@ vi.mock('$env/dynamic/private', () => ({
|
||||
env: { API_INTERNAL_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { load, actions } from './+page.server';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { load } from './+layout.server';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
@@ -35,9 +35,8 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
throw redirect(303, '/admin/users');
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import type { components, operations } from '$lib/generated/api';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
@@ -67,31 +65,3 @@ export async function load({ fetch, url }) {
|
||||
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 isEmpty = $derived(displayFeed.length === 0);
|
||||
@@ -100,11 +108,7 @@ function retry() {
|
||||
{#if data.loadError === 'activity'}
|
||||
<ChronikErrorCard onRetry={retry} />
|
||||
{:else}
|
||||
<ChronikFuerDichBox
|
||||
unread={unread}
|
||||
optimisticMarkRead={notificationStore.optimisticMarkRead}
|
||||
optimisticMarkAllRead={notificationStore.optimisticMarkAllRead}
|
||||
/>
|
||||
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
|
||||
|
||||
<div class="mt-6">
|
||||
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { load, actions } from './+page.server';
|
||||
import { load } from './+page.server';
|
||||
|
||||
const mockApi = {
|
||||
GET: vi.fn(),
|
||||
PATCH: vi.fn(),
|
||||
POST: vi.fn()
|
||||
GET: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: () => mockApi,
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
createApiClient: () => mockApi
|
||||
}));
|
||||
|
||||
function buildUrl(search = ''): URL {
|
||||
@@ -176,84 +173,3 @@ describe('aktivitaeten/load — kinds param per filter', () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
export async function load({ url, fetch, locals }) {
|
||||
@@ -39,7 +39,8 @@ export async function load({ url, fetch, locals }) {
|
||||
})
|
||||
.then((result) => {
|
||||
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 ?? [];
|
||||
})
|
||||
@@ -48,7 +49,8 @@ export async function load({ url, fetch, locals }) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then((result) => {
|
||||
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;
|
||||
if (p) senderName = p.displayName;
|
||||
@@ -60,7 +62,8 @@ export async function load({ url, fetch, locals }) {
|
||||
requests.push(
|
||||
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then((result) => {
|
||||
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;
|
||||
if (p) receiverName = p.displayName;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { load } from './+page.server';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
vi.mock('$lib/shared/errors', () => ({
|
||||
getErrorMessage: (code: string) => code ?? 'Unknown error'
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { components } from '$lib/generated/api';
|
||||
|
||||
@@ -103,7 +103,8 @@ export async function load({ url, fetch }) {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { 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.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!;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
export async function load({
|
||||
@@ -30,7 +30,8 @@ export async function load({
|
||||
]);
|
||||
|
||||
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) {
|
||||
throw error(personsResult.response.status, getErrorMessage('INTERNAL_ERROR'));
|
||||
@@ -75,9 +76,8 @@ export const actions = {
|
||||
// Fetch current document to preserve all existing fields
|
||||
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id: params.id } } });
|
||||
if (!docResult.response.ok) {
|
||||
return fail(docResult.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(docResult.error))
|
||||
});
|
||||
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||
return fail(docResult.response.status, { error: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
const doc = docResult.data!;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
vi.mock('$env/dynamic/private', () => ({ env: { API_INTERNAL_URL: 'http://test-backend:8080' } }));
|
||||
|
||||
import { load } from './+page.server';
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { load } from './+page.server';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
export async function load({
|
||||
@@ -31,7 +31,8 @@ export async function load({
|
||||
]);
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { components } from '$lib/generated/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
@@ -25,7 +25,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
]);
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { PageServerLoad } from './$types';
|
||||
|
||||
@@ -9,7 +9,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
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! };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { PageServerLoad } from './$types';
|
||||
|
||||
@@ -13,7 +13,8 @@ export const load: PageServerLoad = async ({ params, fetch, parent }) => {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
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! };
|
||||
};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { load } from './+page.server';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
export async function load({ params, fetch, locals }) {
|
||||
@@ -32,10 +32,8 @@ export async function load({ params, fetch, locals }) {
|
||||
]);
|
||||
|
||||
if (!personResult.response.ok) {
|
||||
throw error(
|
||||
personResult.response.status,
|
||||
getErrorMessage(extractErrorCode(personResult.error))
|
||||
);
|
||||
const code = (personResult.error as unknown as { code?: string })?.code;
|
||||
throw error(personResult.response.status, getErrorMessage(code));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { error, fail, 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 {
|
||||
normalizePersonType,
|
||||
@@ -25,7 +25,8 @@ export async function load({ params, fetch, locals }) {
|
||||
]);
|
||||
|
||||
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 person = result.data!;
|
||||
@@ -73,9 +74,8 @@ export const actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
updateError: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { updateError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
throw redirect(303, `/persons/${params.id}`);
|
||||
@@ -100,9 +100,8 @@ export const actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
mergeError: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { mergeError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
throw redirect(303, `/persons/${targetPersonId}`);
|
||||
@@ -128,9 +127,8 @@ export const actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
aliasError: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { aliasError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { aliasSuccess: true };
|
||||
@@ -150,9 +148,8 @@ export const actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
aliasError: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { aliasError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { aliasSuccess: true };
|
||||
@@ -169,9 +166,8 @@ export const actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
relationshipError: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { relationshipError: getErrorMessage(code) });
|
||||
}
|
||||
return { relationshipSuccess: true };
|
||||
},
|
||||
@@ -215,9 +211,8 @@ export const actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
relationshipError: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { relationshipError: getErrorMessage(code) });
|
||||
}
|
||||
return { relationshipSuccess: true };
|
||||
},
|
||||
@@ -235,9 +230,8 @@ export const actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
relationshipError: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { relationshipError: getErrorMessage(code) });
|
||||
}
|
||||
return { relationshipSuccess: true };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { load } from './+page.server';
|
||||
|
||||
vi.mock('$lib/shared/api.server', () => ({
|
||||
createApiClient: vi.fn(),
|
||||
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
|
||||
}));
|
||||
vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
|
||||
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { error, fail, 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 {
|
||||
normalizePersonType,
|
||||
@@ -57,8 +57,9 @@ export const actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, {
|
||||
error: getErrorMessage(extractErrorCode(result.error)),
|
||||
error: getErrorMessage(code),
|
||||
personType,
|
||||
title,
|
||||
firstName,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
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';
|
||||
|
||||
const apiBase = () => env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
@@ -27,9 +27,8 @@ export const actions: Actions = {
|
||||
const result = await api.PUT('/api/users/me', { body });
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
updateError: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { updateError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { updateSuccess: true };
|
||||
@@ -51,9 +50,8 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!result.response.ok) {
|
||||
return fail(result.response.status, {
|
||||
passwordError: getErrorMessage(extractErrorCode(result.error))
|
||||
});
|
||||
const code = (result.error as unknown as { code?: string })?.code;
|
||||
return fail(result.response.status, { passwordError: getErrorMessage(code) });
|
||||
}
|
||||
|
||||
return { passwordSuccess: true };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
export async function load({ fetch }) {
|
||||
@@ -9,7 +9,8 @@ export async function load({ fetch }) {
|
||||
if (result.response.status === 401) throw redirect(302, '/login');
|
||||
|
||||
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 network = result.data!;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
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';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
@@ -8,7 +8,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
const result = await api.GET('/api/users/{id}', { params: { path: { id: params.id } } });
|
||||
|
||||
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 { profileUser: result.data! };
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "{job=\"$app\"} |= \"$search\" | json",
|
||||
"expr": "{job=\"$app\"} |= \"$search\" | logfmt",
|
||||
"hide": false,
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
|
||||
@@ -1,702 +0,0 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": { "type": "grafana", "uid": "grafana" },
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Product owner overview — system health, user activity, archive progress, and OCR quality at a weekly glance.",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||
"id": 100,
|
||||
"title": "System Health",
|
||||
"type": "row",
|
||||
"panels": []
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Backend Status",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "up{job=\"spring-boot\"}",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" } } },
|
||||
{ "type": "value", "options": { "1": { "text": "UP", "color": "green" } } }
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Server Errors (5xx)",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(increase(http_server_requests_seconds_count{status=~\"5..\"}[$__range]))",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 1 },
|
||||
{ "color": "red", "value": 6 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Response Time (p95)",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[$__range])) by (le))",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s",
|
||||
"decimals": 2,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.5 },
|
||||
{ "color": "red", "value": 2 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Error Log Count",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(count_over_time({compose_service=\"backend\"} | json | level=\"ERROR\" [$__range]))",
|
||||
"queryType": "instant",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 1 },
|
||||
{ "color": "red", "value": 10 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "CPU Usage",
|
||||
"type": "bargauge",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 5 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"decimals": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 70 },
|
||||
{ "color": "red", "value": 85 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"displayMode": "gradient",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"showUnfilled": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Memory Usage",
|
||||
"type": "bargauge",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 5, "w": 8, "x": 8, "y": 5 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"decimals": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 70 },
|
||||
{ "color": "red", "value": 85 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"displayMode": "gradient",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"showUnfilled": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Disk Usage",
|
||||
"type": "bargauge",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 5, "w": 8, "x": 16, "y": 5 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "(1 - (node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"})) * 100",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"decimals": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 70 },
|
||||
{ "color": "red", "value": 80 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"displayMode": "gradient",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"showUnfilled": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 10 },
|
||||
"id": 101,
|
||||
"title": "User Activity",
|
||||
"type": "row",
|
||||
"panels": []
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "Active Users",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "postgres", "uid": "postgres" },
|
||||
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 11 },
|
||||
"targets": [
|
||||
{
|
||||
"rawSql": "SELECT COUNT(DISTINCT actor_id) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS'",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 0,
|
||||
"color": { "mode": "fixed", "fixedColor": "blue" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"title": "Total Logins",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "postgres", "uid": "postgres" },
|
||||
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 11 },
|
||||
"targets": [
|
||||
{
|
||||
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS'",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 0,
|
||||
"color": { "mode": "fixed", "fixedColor": "blue" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"title": "Failed Login Attempts",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "postgres", "uid": "postgres" },
|
||||
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 11 },
|
||||
"targets": [
|
||||
{
|
||||
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind IN ('LOGIN_FAILED', 'LOGIN_RATE_LIMITED')",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 1 },
|
||||
{ "color": "red", "value": 4 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Daily Logins (last 7 days)",
|
||||
"type": "barchart",
|
||||
"datasource": { "type": "postgres", "uid": "postgres" },
|
||||
"gridPos": { "h": 7, "w": 24, "x": 0, "y": 15 },
|
||||
"targets": [
|
||||
{
|
||||
"rawSql": "SELECT DATE_TRUNC('day', happened_at) AS time, COUNT(*) AS logins FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS' GROUP BY 1 ORDER BY 1",
|
||||
"format": "time_series",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 0,
|
||||
"color": { "mode": "fixed", "fixedColor": "blue" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"legend": { "displayMode": "hidden" },
|
||||
"orientation": "auto",
|
||||
"showValue": "auto",
|
||||
"stacking": "none",
|
||||
"xTickLabelRotation": 0,
|
||||
"xTickLabelSpacing": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 },
|
||||
"id": 102,
|
||||
"title": "Archive Progress",
|
||||
"type": "row",
|
||||
"panels": []
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"title": "Transcription Coverage",
|
||||
"type": "bargauge",
|
||||
"datasource": { "type": "postgres", "uid": "postgres" },
|
||||
"gridPos": { "h": 5, "w": 24, "x": 0, "y": 23 },
|
||||
"targets": [
|
||||
{
|
||||
"rawSql": "SELECT (COUNT(*) FILTER (WHERE text IS NOT NULL AND text <> ''))::float * 100.0 / NULLIF(COUNT(*), 0) AS percent_complete FROM transcription_blocks",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"decimals": 1,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "yellow", "value": 25 },
|
||||
{ "color": "green", "value": 75 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"displayMode": "gradient",
|
||||
"orientation": "horizontal",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"showUnfilled": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"title": "Total Documents",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "postgres", "uid": "postgres" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 28 },
|
||||
"targets": [
|
||||
{
|
||||
"rawSql": "SELECT COUNT(*) AS value FROM documents WHERE status <> 'PLACEHOLDER'",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 0,
|
||||
"color": { "mode": "fixed", "fixedColor": "blue" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"title": "Uploads This Week",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "postgres", "uid": "postgres" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 28 },
|
||||
"targets": [
|
||||
{
|
||||
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'FILE_UPLOADED'",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 0,
|
||||
"color": { "mode": "fixed", "fixedColor": "blue" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"title": "Blocks Transcribed This Week",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "postgres", "uid": "postgres" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 28 },
|
||||
"targets": [
|
||||
{
|
||||
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'TEXT_SAVED'",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 0,
|
||||
"color": { "mode": "fixed", "fixedColor": "blue" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"title": "Blocks Reviewed This Week",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "postgres", "uid": "postgres" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 28 },
|
||||
"targets": [
|
||||
{
|
||||
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'BLOCK_REVIEWED'",
|
||||
"format": "table",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 0,
|
||||
"color": { "mode": "fixed", "fixedColor": "blue" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 },
|
||||
"id": 103,
|
||||
"title": "OCR Health",
|
||||
"type": "row",
|
||||
"panels": []
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"title": "OCR Jobs",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 33 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(increase(ocr_jobs_total[$__range]))",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short",
|
||||
"decimals": 0,
|
||||
"color": { "mode": "fixed", "fixedColor": "blue" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"title": "OCR Page Error Rate",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 33 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(increase(ocr_skipped_pages_total[$__range])) / clamp_min(sum(increase(ocr_pages_total[$__range])), 1)",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit",
|
||||
"decimals": 1,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.01 },
|
||||
{ "color": "red", "value": 0.05 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"title": "Illegible Word Rate",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 33 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(increase(ocr_illegible_words_total[$__range])) / clamp_min(sum(increase(ocr_words_total[$__range])), 1)",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit",
|
||||
"decimals": 1,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 0.1 },
|
||||
{ "color": "red", "value": 0.25 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"title": "OCR Service Status",
|
||||
"type": "stat",
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 33 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "ocr_models_ready",
|
||||
"instant": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{ "type": "value", "options": { "0": { "text": "NOT READY", "color": "red" } } },
|
||||
{ "type": "value", "options": { "1": { "text": "READY", "color": "green" } } }
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
},
|
||||
"color": { "mode": "thresholds" }
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "value"
|
||||
}
|
||||
}
|
||||
],
|
||||
"refresh": "",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["po-overview", "familienarchiv"],
|
||||
"templating": { "list": [] },
|
||||
"time": { "from": "now-7d", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "PO Overview",
|
||||
"uid": "po-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -36,19 +36,3 @@ datasources:
|
||||
datasourceUid: prometheus
|
||||
nodeGraph:
|
||||
enabled: true
|
||||
|
||||
# Read-only PostgreSQL datasource for the PO Overview dashboard (issue #651).
|
||||
# Uses the grafana_reader role provisioned by Flyway V68. Traffic stays inside
|
||||
# archiv-net, so sslmode=disable is the deliberate, accepted setting.
|
||||
- name: PostgreSQL
|
||||
type: postgres
|
||||
uid: postgres
|
||||
url: archive-db:5432
|
||||
user: grafana_reader
|
||||
editable: false
|
||||
secureJsonData:
|
||||
password: ${GRAFANA_DB_PASSWORD}
|
||||
jsonData:
|
||||
database: ${POSTGRES_DB}
|
||||
sslmode: disable
|
||||
postgresVersion: 1600
|
||||
|
||||
@@ -16,11 +16,6 @@ GLITCHTIP_DOMAIN=https://glitchtip.archiv.raddatz.cloud
|
||||
|
||||
POSTGRES_USER=archiv
|
||||
|
||||
# Note: GRAFANA_DB_PASSWORD is a secret and is injected by CI from
|
||||
# obs-secrets.env (see .env.example for the local-dev declaration).
|
||||
# It is consumed by both archive-backend (Flyway V68 placeholder) and
|
||||
# obs-grafana (PostgreSQL datasource).
|
||||
|
||||
# PostgreSQL hostname for GlitchTip db-init and workers.
|
||||
# The actual value depends on the Compose project name — it is not a fixed string.
|
||||
# CI sets POSTGRES_HOST in obs-secrets.env per environment:
|
||||
|
||||
@@ -20,4 +20,7 @@ scrape_configs:
|
||||
- job_name: ocr-service
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
# TODO: remove or add prometheus-client to ocr-service.
|
||||
# The Python OCR service does not currently expose Prometheus metrics.
|
||||
# This target will show as DOWN until prometheus-client is added to ocr-service.
|
||||
- targets: ['ocr:8000']
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
import glob
|
||||
import inspect
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
@@ -11,11 +10,9 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Awaitable, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
@@ -23,11 +20,8 @@ import pypdfium2 as pdfium
|
||||
from fastapi import FastAPI, Form, Header, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from PIL import Image
|
||||
from prometheus_client import REGISTRY
|
||||
from prometheus_fastapi_instrumentator import Instrumentator
|
||||
|
||||
from confidence import apply_confidence_markers, get_threshold
|
||||
from metrics import OcrMetrics, build_metrics
|
||||
from spell_check import correct_text, load_spell_checker
|
||||
from engines import kraken as kraken_engine
|
||||
from engines import surya as surya_engine
|
||||
@@ -43,12 +37,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_models_ready = False
|
||||
|
||||
# One-shot import-time binding to the default REGISTRY. Tests that need a
|
||||
# clean counter state must monkeypatch `main.metrics` with a container built
|
||||
# from a fresh CollectorRegistry — rebinding through the registry directly
|
||||
# will not retarget the references stored in the OcrMetrics dataclass.
|
||||
metrics: OcrMetrics = build_metrics(REGISTRY)
|
||||
|
||||
ALLOWED_PDF_HOSTS = set(
|
||||
h.strip() for h in os.getenv("ALLOWED_PDF_HOSTS", "minio,localhost,127.0.0.1").split(",")
|
||||
)
|
||||
@@ -56,42 +44,6 @@ ALLOWED_PDF_HOSTS = set(
|
||||
_SPELL_CHECK_SCRIPT_TYPES = {"HANDWRITING_KURRENT", "HANDWRITING_LATIN"}
|
||||
|
||||
|
||||
async def _record_training(
|
||||
runner: Callable[[], Awaitable[dict] | dict],
|
||||
kind: str,
|
||||
) -> dict:
|
||||
"""Run a training callable and record outcome + accuracy metrics.
|
||||
|
||||
Wraps the per-endpoint try/except + outcome counter + accuracy gauge
|
||||
block that used to be repeated at /train, /train-sender, and /segtrain.
|
||||
The runner returns a dict with at least an `accuracy` key; if its value
|
||||
is None, the gauge is left at its default.
|
||||
"""
|
||||
try:
|
||||
result = runner()
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
except Exception:
|
||||
metrics.ocr_training_runs_total.labels(kind=kind, outcome="error").inc()
|
||||
raise
|
||||
metrics.ocr_training_runs_total.labels(kind=kind, outcome="success").inc()
|
||||
if result.get("accuracy") is not None:
|
||||
metrics.ocr_model_accuracy.labels(kind=kind).set(result["accuracy"])
|
||||
return result
|
||||
|
||||
|
||||
def _observe_block_words(words: list[dict], threshold: float) -> None:
|
||||
"""Record per-block word counts and below-threshold word counts.
|
||||
|
||||
Pre: `words` is non-empty. Caller checks for that — keeping the helper
|
||||
branch-free makes the call sites read as a single line.
|
||||
"""
|
||||
metrics.ocr_words_total.inc(len(words))
|
||||
metrics.ocr_illegible_words_total.inc(
|
||||
sum(1 for w in words if w["confidence"] < threshold)
|
||||
)
|
||||
|
||||
|
||||
def _validate_url(url: str) -> None:
|
||||
"""Validate that the PDF URL points to an allowed host (SSRF protection)."""
|
||||
parsed = urlparse(url)
|
||||
@@ -111,7 +63,6 @@ async def lifespan(app: FastAPI):
|
||||
kraken_engine.load_models()
|
||||
load_spell_checker()
|
||||
_models_ready = True
|
||||
metrics.ocr_models_ready.set(1)
|
||||
logger.info("Startup complete — ready to accept requests")
|
||||
|
||||
yield
|
||||
@@ -121,28 +72,6 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="Familienarchiv OCR Service", lifespan=lifespan)
|
||||
|
||||
# /metrics is unauthenticated — relies on Docker-internal-network exposure
|
||||
# only (CWE-200 risk if `ports:` ever maps 8000 to host). See
|
||||
# docs/OBSERVABILITY.md §Internal-only endpoints for the Caddy block snippet.
|
||||
Instrumentator(excluded_handlers=["/health", "/metrics"]).instrument(app).expose(app)
|
||||
|
||||
|
||||
class MetricsPathFilter(logging.Filter):
|
||||
"""Drop uvicorn.access entries for /metrics and /health to keep logs focused."""
|
||||
|
||||
_SUPPRESSED_PATHS = {"/metrics", "/health"}
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
# uvicorn.access formats as: '%s - "%s %s HTTP/%s" %d'
|
||||
if record.args and len(record.args) >= 3:
|
||||
path = record.args[2]
|
||||
if isinstance(path, str) and path in self._SUPPRESSED_PATHS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
logging.getLogger("uvicorn.access").addFilter(MetricsPathFilter())
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
@@ -170,9 +99,7 @@ async def run_ocr(request: OcrRequest):
|
||||
del img
|
||||
|
||||
script_type = request.scriptType.upper()
|
||||
engine_name = "kraken" if script_type == "HANDWRITING_KURRENT" else "surya"
|
||||
|
||||
extract_started = time.monotonic()
|
||||
if script_type == "HANDWRITING_KURRENT":
|
||||
if not kraken_engine.is_available():
|
||||
raise HTTPException(
|
||||
@@ -184,18 +111,11 @@ async def run_ocr(request: OcrRequest):
|
||||
else:
|
||||
# TYPEWRITER, HANDWRITING_LATIN, UNKNOWN — all use Surya
|
||||
blocks = await asyncio.to_thread(surya_engine.extract_blocks, images, request.language)
|
||||
metrics.ocr_processing_seconds.labels(engine=engine_name).observe(
|
||||
time.monotonic() - extract_started
|
||||
)
|
||||
|
||||
metrics.ocr_jobs_total.labels(engine=engine_name, script_type=script_type).inc()
|
||||
|
||||
threshold = get_threshold(script_type)
|
||||
for block in blocks:
|
||||
words = block.get("words") or []
|
||||
if words:
|
||||
_observe_block_words(words, threshold)
|
||||
block["text"] = apply_confidence_markers(words, threshold)
|
||||
if block.get("words"):
|
||||
block["text"] = apply_confidence_markers(block["words"], threshold)
|
||||
block.pop("words", None)
|
||||
if script_type in _SPELL_CHECK_SCRIPT_TYPES:
|
||||
block["text"] = correct_text(block["text"])
|
||||
@@ -226,9 +146,6 @@ async def run_ocr_stream(request: OcrRequest):
|
||||
)
|
||||
|
||||
engine = kraken_engine if use_kraken else surya_engine
|
||||
engine_name = "kraken" if use_kraken else "surya"
|
||||
|
||||
metrics.ocr_jobs_total.labels(engine=engine_name, script_type=script_type).inc()
|
||||
|
||||
if request.regions:
|
||||
# Guided mode: recognize only the user-drawn annotation regions
|
||||
@@ -259,15 +176,12 @@ async def run_ocr_stream(request: OcrRequest):
|
||||
image = await asyncio.to_thread(preprocess_page, image)
|
||||
blocks = []
|
||||
sender_path = request.senderModelPath if use_kraken else None
|
||||
engine_seconds = 0.0
|
||||
for region in page_regions:
|
||||
region_started = time.monotonic()
|
||||
text = await asyncio.to_thread(
|
||||
engine.extract_region_text, image,
|
||||
region.x, region.y, region.width, region.height,
|
||||
sender_path,
|
||||
)
|
||||
engine_seconds += time.monotonic() - region_started
|
||||
if script_type in _SPELL_CHECK_SCRIPT_TYPES:
|
||||
text = correct_text(text)
|
||||
blocks.append({
|
||||
@@ -281,11 +195,7 @@ async def run_ocr_stream(request: OcrRequest):
|
||||
"annotationId": region.annotationId,
|
||||
})
|
||||
|
||||
metrics.ocr_processing_seconds.labels(engine=engine_name).observe(
|
||||
engine_seconds
|
||||
)
|
||||
total_blocks += len(blocks)
|
||||
metrics.ocr_pages_total.labels(engine=engine_name).inc()
|
||||
yield json.dumps({
|
||||
"type": "page",
|
||||
"pageNumber": page_idx,
|
||||
@@ -295,7 +205,6 @@ async def run_ocr_stream(request: OcrRequest):
|
||||
except Exception:
|
||||
logger.exception("Guided OCR failed on page %d", page_idx)
|
||||
skipped_pages += 1
|
||||
metrics.ocr_skipped_pages_total.inc()
|
||||
yield json.dumps({
|
||||
"type": "error",
|
||||
"pageNumber": page_idx,
|
||||
@@ -329,25 +238,18 @@ async def run_ocr_stream(request: OcrRequest):
|
||||
yield json.dumps({"type": "preprocessing", "pageNumber": page_idx}) + "\n"
|
||||
image = await asyncio.to_thread(preprocess_page, image)
|
||||
sender_path = request.senderModelPath if use_kraken else None
|
||||
page_started = time.monotonic()
|
||||
blocks = await asyncio.to_thread(
|
||||
engine.extract_page_blocks, image, page_idx, request.language, sender_path
|
||||
)
|
||||
metrics.ocr_processing_seconds.labels(engine=engine_name).observe(
|
||||
time.monotonic() - page_started
|
||||
)
|
||||
|
||||
for block in blocks:
|
||||
words = block.get("words") or []
|
||||
if words:
|
||||
_observe_block_words(words, threshold)
|
||||
block["text"] = apply_confidence_markers(words, threshold)
|
||||
if block.get("words"):
|
||||
block["text"] = apply_confidence_markers(block["words"], threshold)
|
||||
block.pop("words", None)
|
||||
if script_type in _SPELL_CHECK_SCRIPT_TYPES:
|
||||
block["text"] = correct_text(block["text"])
|
||||
|
||||
total_blocks += len(blocks)
|
||||
metrics.ocr_pages_total.labels(engine=engine_name).inc()
|
||||
yield json.dumps({
|
||||
"type": "page",
|
||||
"pageNumber": page_idx,
|
||||
@@ -357,7 +259,6 @@ async def run_ocr_stream(request: OcrRequest):
|
||||
except Exception:
|
||||
logger.exception("OCR failed on page %d", page_idx)
|
||||
skipped_pages += 1
|
||||
metrics.ocr_skipped_pages_total.inc()
|
||||
yield json.dumps({
|
||||
"type": "error",
|
||||
"pageNumber": page_idx,
|
||||
@@ -537,7 +438,8 @@ async def train_model(
|
||||
|
||||
return {"loss": None, "accuracy": accuracy, "cer": cer, "epochs": epochs}
|
||||
|
||||
return await _record_training(lambda: asyncio.to_thread(_run_training), kind="recognition")
|
||||
result = await asyncio.to_thread(_run_training)
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/train-sender")
|
||||
@@ -616,9 +518,8 @@ async def train_sender_model(
|
||||
|
||||
return {"loss": None, "accuracy": accuracy, "cer": cer, "epochs": epochs}
|
||||
|
||||
return await _record_training(
|
||||
lambda: asyncio.to_thread(_run_sender_training), kind="recognition"
|
||||
)
|
||||
result = await asyncio.to_thread(_run_sender_training)
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/segtrain")
|
||||
@@ -727,7 +628,8 @@ async def segtrain_model(
|
||||
|
||||
return {"loss": None, "accuracy": accuracy, "cer": cer, "epochs": epochs}
|
||||
|
||||
return await _record_training(lambda: asyncio.to_thread(_run_segtrain), kind="segmentation")
|
||||
result = await asyncio.to_thread(_run_segtrain)
|
||||
return result
|
||||
|
||||
|
||||
async def _download_and_convert_pdf(url: str) -> list[Image.Image]:
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Prometheus metric definitions for the OCR service.
|
||||
|
||||
`build_metrics(registry)` returns a fresh `OcrMetrics` instance bound to the
|
||||
given `CollectorRegistry`. Production code calls it once at module load with
|
||||
the default `REGISTRY`; tests pass a per-test `CollectorRegistry()` to keep
|
||||
counter values isolated between cases (decision #3 on issue #652).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OcrMetrics:
|
||||
"""Container for every custom OCR metric.
|
||||
|
||||
Counters and gauges are immutable references to `prometheus_client`
|
||||
instances. Mutating them (`.inc()`, `.observe()`, `.set()`) is safe;
|
||||
rebinding the field on the dataclass is not — use `build_metrics` to get
|
||||
a new container.
|
||||
"""
|
||||
|
||||
ocr_jobs_total: Counter
|
||||
ocr_pages_total: Counter
|
||||
ocr_skipped_pages_total: Counter
|
||||
ocr_words_total: Counter
|
||||
ocr_illegible_words_total: Counter
|
||||
ocr_processing_seconds: Histogram
|
||||
ocr_training_runs_total: Counter
|
||||
ocr_model_accuracy: Gauge
|
||||
ocr_models_ready: Gauge
|
||||
|
||||
|
||||
def build_metrics(registry: CollectorRegistry) -> OcrMetrics:
|
||||
"""Create one OcrMetrics instance bound to `registry`."""
|
||||
return OcrMetrics(
|
||||
ocr_jobs_total=Counter(
|
||||
"ocr_jobs_total",
|
||||
"Number of OCR jobs processed, labelled by engine and script type.",
|
||||
["engine", "script_type"],
|
||||
registry=registry,
|
||||
),
|
||||
ocr_pages_total=Counter(
|
||||
"ocr_pages_total",
|
||||
"Number of pages successfully OCR'd, labelled by engine.",
|
||||
["engine"],
|
||||
registry=registry,
|
||||
),
|
||||
ocr_skipped_pages_total=Counter(
|
||||
"ocr_skipped_pages_total",
|
||||
"Number of pages skipped because the OCR engine raised.",
|
||||
registry=registry,
|
||||
),
|
||||
ocr_words_total=Counter(
|
||||
"ocr_words_total",
|
||||
"Number of words recognized across all OCR blocks.",
|
||||
registry=registry,
|
||||
),
|
||||
ocr_illegible_words_total=Counter(
|
||||
"ocr_illegible_words_total",
|
||||
"Number of words below the confidence threshold "
|
||||
"(replaced with [unleserlich]).",
|
||||
registry=registry,
|
||||
),
|
||||
ocr_processing_seconds=Histogram(
|
||||
"ocr_processing_seconds",
|
||||
"OCR processing time per page (streaming) or per document (non-streaming).",
|
||||
["engine"],
|
||||
registry=registry,
|
||||
),
|
||||
ocr_training_runs_total=Counter(
|
||||
"ocr_training_runs_total",
|
||||
"Number of training runs, labelled by kind (recognition|segmentation) "
|
||||
"and outcome (success|error).",
|
||||
["kind", "outcome"],
|
||||
registry=registry,
|
||||
),
|
||||
ocr_model_accuracy=Gauge(
|
||||
"ocr_model_accuracy",
|
||||
"Latest model accuracy reported by a successful training run.",
|
||||
["kind"],
|
||||
registry=registry,
|
||||
),
|
||||
ocr_models_ready=Gauge(
|
||||
"ocr_models_ready",
|
||||
"1 once the lifespan startup has finished loading models, 0 before.",
|
||||
registry=registry,
|
||||
),
|
||||
)
|
||||
@@ -10,5 +10,3 @@ pyvips>=2.2.0
|
||||
httpx==0.28.1
|
||||
pyspellchecker==0.9.0
|
||||
opencv-python-headless==4.11.0.86
|
||||
prometheus-fastapi-instrumentator==7.0.0
|
||||
prometheus-client==0.25.0
|
||||
|
||||
@@ -1,638 +0,0 @@
|
||||
"""Tests for Prometheus metrics exposed by the OCR service.
|
||||
|
||||
Each test that asserts on a counter/gauge value uses a fresh CollectorRegistry
|
||||
(see decision #3 on issue #652) to keep the metrics isolated between tests.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import zipfile
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from PIL import Image
|
||||
from prometheus_client import CollectorRegistry
|
||||
|
||||
from main import app
|
||||
from metrics import build_metrics
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def ocr_client(*, raise_app_exceptions: bool = True):
|
||||
"""Yield an AsyncClient with model-loaders patched and _models_ready forced on.
|
||||
|
||||
The shared setup for almost every metrics test: stub the heavy lifecycle
|
||||
hooks (kraken_engine.load_models, load_spell_checker), flip the readiness
|
||||
flag so request handlers do not 503, and restore it afterwards.
|
||||
"""
|
||||
with patch("main.kraken_engine.load_models"), \
|
||||
patch("main.load_spell_checker"):
|
||||
transport = ASGITransport(app=app, raise_app_exceptions=raise_app_exceptions)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
import main as main_module
|
||||
main_module._models_ready = True
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
main_module._models_ready = False
|
||||
|
||||
|
||||
def _minimal_zip() -> bytes:
|
||||
"""Return a ZIP containing one fake .xml so endpoint validation passes."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("page_01.xml", "<PcGts/>")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _fake_training_result(accuracy: float = 0.91) -> dict:
|
||||
return {"loss": None, "accuracy": accuracy, "cer": round(1 - accuracy, 4), "epochs": 5}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_metrics(monkeypatch):
|
||||
"""Replace the module-level `main.metrics` with one bound to a fresh registry."""
|
||||
registry = CollectorRegistry()
|
||||
test_metrics = build_metrics(registry)
|
||||
monkeypatch.setattr("main.metrics", test_metrics)
|
||||
return test_metrics
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_endpoint_returns_200():
|
||||
"""`GET /metrics` returns 200 with Prometheus exposition content.
|
||||
|
||||
Uses the global REGISTRY by design — does NOT take the `fresh_metrics` fixture.
|
||||
The `/metrics` endpoint is wired by `prometheus-fastapi-instrumentator`, which
|
||||
binds to the default REGISTRY at app-construction time; swapping `main.metrics`
|
||||
via the fixture would not redirect what `/metrics` exposes. This test only
|
||||
asserts response shape (status code + content-type substring), not numeric
|
||||
counter values, so cross-test state leakage cannot affect it.
|
||||
"""
|
||||
with patch("main.kraken_engine.load_models"), \
|
||||
patch("main.load_spell_checker"):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.get("/metrics")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "text/plain" in response.headers.get("content-type", "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metrics_includes_http_request_metrics_after_ocr_call():
|
||||
"""After a request to /ocr, `/metrics` exposes auto-instrumented http_* metrics.
|
||||
|
||||
Uses the global REGISTRY by design — does NOT take the `fresh_metrics` fixture.
|
||||
The `http_requests_total` / `http_request_duration_seconds` metrics live on
|
||||
the instrumentator's default REGISTRY (not on `main.metrics`), so a fresh
|
||||
CollectorRegistry would never see them. This test only asserts response shape
|
||||
(substring presence in the exposition body), not numeric counter values, so
|
||||
cross-test state leakage cannot affect it.
|
||||
"""
|
||||
mock_images = [Image.new("RGB", (100, 100))]
|
||||
mock_blocks = [{"pageNumber": 1, "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0,
|
||||
"polygon": None, "text": "hi", "words": []}]
|
||||
|
||||
with patch("main._download_and_convert_pdf", new_callable=AsyncMock, return_value=mock_images), \
|
||||
patch("main.preprocess_page", side_effect=lambda img: img), \
|
||||
patch("main.surya_engine.extract_blocks", return_value=mock_blocks):
|
||||
async with ocr_client() as client:
|
||||
ocr_response = await client.post("/ocr", json={
|
||||
"pdfUrl": "http://minio/doc.pdf",
|
||||
"scriptType": "TYPEWRITER",
|
||||
"language": "de",
|
||||
})
|
||||
assert ocr_response.status_code == 200, ocr_response.text
|
||||
|
||||
metrics_response = await client.get("/metrics")
|
||||
|
||||
body = metrics_response.text
|
||||
assert "http_requests_total" in body
|
||||
assert "http_request_duration_seconds" in body
|
||||
|
||||
|
||||
def test_build_metrics_registers_all_custom_metrics_on_given_registry():
|
||||
"""`build_metrics` returns an OcrMetrics bound to the supplied registry."""
|
||||
registry = CollectorRegistry()
|
||||
metrics = build_metrics(registry)
|
||||
|
||||
metric_names = {m.name for m in registry.collect()}
|
||||
expected = {
|
||||
"ocr_jobs",
|
||||
"ocr_pages",
|
||||
"ocr_skipped_pages",
|
||||
"ocr_words",
|
||||
"ocr_illegible_words",
|
||||
"ocr_processing_seconds",
|
||||
"ocr_training_runs",
|
||||
"ocr_model_accuracy",
|
||||
"ocr_models_ready",
|
||||
}
|
||||
assert expected <= metric_names, f"missing: {expected - metric_names}"
|
||||
|
||||
# A second registry yields a separate container — no shared state.
|
||||
other_metrics = build_metrics(CollectorRegistry())
|
||||
assert metrics is not other_metrics
|
||||
|
||||
|
||||
async def _drive_ocr(client: AsyncClient, *, script_type: str) -> None:
|
||||
"""Helper — fires /ocr with a single mocked page and asserts a 200."""
|
||||
response = await client.post("/ocr", json={
|
||||
"pdfUrl": "http://minio/doc.pdf",
|
||||
"scriptType": script_type,
|
||||
"language": "de",
|
||||
})
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_jobs_total_incremented_with_kraken_engine_label_for_kurrent(fresh_metrics):
|
||||
"""A /ocr call with HANDWRITING_KURRENT increments engine=kraken."""
|
||||
mock_images = [Image.new("RGB", (100, 100))]
|
||||
mock_blocks = [{"pageNumber": 1, "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0,
|
||||
"polygon": None, "text": "hi", "words": []}]
|
||||
|
||||
with patch("main.correct_text", side_effect=lambda t: t), \
|
||||
patch("main._download_and_convert_pdf", new_callable=AsyncMock, return_value=mock_images), \
|
||||
patch("main.preprocess_page", side_effect=lambda img: img), \
|
||||
patch("main.kraken_engine.is_available", return_value=True), \
|
||||
patch("main.kraken_engine.extract_blocks", return_value=mock_blocks):
|
||||
async with ocr_client() as client:
|
||||
await _drive_ocr(client, script_type="HANDWRITING_KURRENT")
|
||||
|
||||
value = fresh_metrics.ocr_jobs_total.labels(
|
||||
engine="kraken", script_type="HANDWRITING_KURRENT"
|
||||
)._value.get()
|
||||
assert value == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_jobs_total_incremented_with_surya_engine_label_for_typewriter(fresh_metrics):
|
||||
"""A /ocr call with TYPEWRITER increments engine=surya."""
|
||||
mock_images = [Image.new("RGB", (100, 100))]
|
||||
mock_blocks = [{"pageNumber": 1, "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0,
|
||||
"polygon": None, "text": "hi", "words": []}]
|
||||
|
||||
with patch("main._download_and_convert_pdf", new_callable=AsyncMock, return_value=mock_images), \
|
||||
patch("main.preprocess_page", side_effect=lambda img: img), \
|
||||
patch("main.surya_engine.extract_blocks", return_value=mock_blocks):
|
||||
async with ocr_client() as client:
|
||||
await _drive_ocr(client, script_type="TYPEWRITER")
|
||||
|
||||
value = fresh_metrics.ocr_jobs_total.labels(
|
||||
engine="surya", script_type="TYPEWRITER"
|
||||
)._value.get()
|
||||
assert value == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_pages_total_incremented_once_per_page_in_stream(fresh_metrics):
|
||||
"""The /ocr/stream generator increments ocr_pages_total per successful page."""
|
||||
mock_images = [Image.new("RGB", (100, 100)) for _ in range(3)]
|
||||
mock_blocks = [{"pageNumber": 1, "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0,
|
||||
"polygon": None, "text": "hi", "words": []}]
|
||||
|
||||
with patch("main._download_and_convert_pdf", new_callable=AsyncMock, return_value=mock_images), \
|
||||
patch("main.preprocess_page", side_effect=lambda img: img), \
|
||||
patch("main.surya_engine.extract_page_blocks", return_value=mock_blocks):
|
||||
async with ocr_client() as client:
|
||||
async with client.stream("POST", "/ocr/stream", json={
|
||||
"pdfUrl": "http://minio/doc.pdf",
|
||||
"scriptType": "TYPEWRITER",
|
||||
"language": "de",
|
||||
}) as response:
|
||||
assert response.status_code == 200
|
||||
# Drain the stream so all per-page increments fire.
|
||||
async for _ in response.aiter_lines():
|
||||
pass
|
||||
|
||||
value = fresh_metrics.ocr_pages_total.labels(engine="surya")._value.get()
|
||||
assert value == 3.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_skipped_pages_total_incremented_when_engine_raises_for_a_page(fresh_metrics):
|
||||
"""When the engine raises on a page, ocr_skipped_pages_total bumps and the stream finishes."""
|
||||
mock_images = [Image.new("RGB", (100, 100)) for _ in range(2)]
|
||||
good_blocks = [{"pageNumber": 1, "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0,
|
||||
"polygon": None, "text": "ok", "words": []}]
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def extract_side_effect(*args, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
raise RuntimeError("synthetic engine failure")
|
||||
return good_blocks
|
||||
|
||||
with patch("main._download_and_convert_pdf", new_callable=AsyncMock, return_value=mock_images), \
|
||||
patch("main.preprocess_page", side_effect=lambda img: img), \
|
||||
patch("main.surya_engine.extract_page_blocks", side_effect=extract_side_effect):
|
||||
async with ocr_client() as client:
|
||||
async with client.stream("POST", "/ocr/stream", json={
|
||||
"pdfUrl": "http://minio/doc.pdf",
|
||||
"scriptType": "TYPEWRITER",
|
||||
"language": "de",
|
||||
}) as response:
|
||||
assert response.status_code == 200
|
||||
saw_error = False
|
||||
async for line in response.aiter_lines():
|
||||
if line and '"type": "error"' in line:
|
||||
saw_error = True
|
||||
assert saw_error
|
||||
|
||||
assert fresh_metrics.ocr_skipped_pages_total._value.get() == 1.0
|
||||
# The second page still succeeds.
|
||||
assert fresh_metrics.ocr_pages_total.labels(engine="surya")._value.get() == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_words_and_illegible_words_total_sum_across_blocks(fresh_metrics):
|
||||
"""Counters reflect totals summed over every block in the request.
|
||||
|
||||
Threshold defaults to THRESHOLD_DEFAULT (0.3) for non-Kurrent scripts. Two
|
||||
blocks: 3 words above + 2 words below threshold across blocks.
|
||||
"""
|
||||
mock_images = [Image.new("RGB", (100, 100))]
|
||||
mock_blocks = [
|
||||
{"pageNumber": 1, "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0,
|
||||
"polygon": None, "text": "ignored",
|
||||
"words": [{"text": "Lieber", "confidence": 0.9},
|
||||
{"text": "Freund", "confidence": 0.1}]},
|
||||
{"pageNumber": 1, "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0,
|
||||
"polygon": None, "text": "ignored",
|
||||
"words": [{"text": "Gruss", "confidence": 0.8},
|
||||
{"text": "verschmiert", "confidence": 0.05},
|
||||
{"text": "Karl", "confidence": 0.95}]},
|
||||
]
|
||||
|
||||
with patch("main._download_and_convert_pdf", new_callable=AsyncMock, return_value=mock_images), \
|
||||
patch("main.preprocess_page", side_effect=lambda img: img), \
|
||||
patch("main.surya_engine.extract_blocks", return_value=mock_blocks):
|
||||
async with ocr_client() as client:
|
||||
await _drive_ocr(client, script_type="TYPEWRITER")
|
||||
|
||||
assert fresh_metrics.ocr_words_total._value.get() == 5.0
|
||||
assert fresh_metrics.ocr_illegible_words_total._value.get() == 2.0
|
||||
|
||||
|
||||
def _histogram_count_sum(histogram, **labels) -> tuple[float, float]:
|
||||
"""Read the per-label-set _count and _sum from a prometheus_client Histogram."""
|
||||
child = histogram.labels(**labels)
|
||||
return child._sum.get(), sum(b.get() for b in child._buckets)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_processing_seconds_histogram_observed_per_page_in_stream(fresh_metrics):
|
||||
"""The streaming generator observes ocr_processing_seconds once per page."""
|
||||
mock_images = [Image.new("RGB", (100, 100)) for _ in range(2)]
|
||||
mock_blocks = [{"pageNumber": 1, "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0,
|
||||
"polygon": None, "text": "ok", "words": []}]
|
||||
|
||||
with patch("main._download_and_convert_pdf", new_callable=AsyncMock, return_value=mock_images), \
|
||||
patch("main.preprocess_page", side_effect=lambda img: img), \
|
||||
patch("main.surya_engine.extract_page_blocks", return_value=mock_blocks):
|
||||
async with ocr_client() as client:
|
||||
async with client.stream("POST", "/ocr/stream", json={
|
||||
"pdfUrl": "http://minio/doc.pdf",
|
||||
"scriptType": "TYPEWRITER",
|
||||
"language": "de",
|
||||
}) as response:
|
||||
assert response.status_code == 200
|
||||
async for _ in response.aiter_lines():
|
||||
pass
|
||||
|
||||
sum_seconds, count = _histogram_count_sum(
|
||||
fresh_metrics.ocr_processing_seconds, engine="surya"
|
||||
)
|
||||
assert count == 2.0
|
||||
assert sum_seconds >= 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_training_runs_total_incremented_with_recognition_success_label(fresh_metrics):
|
||||
"""/train success increments ocr_training_runs_total{kind=recognition, outcome=success}."""
|
||||
async def fake_to_thread(func, *args, **kwargs):
|
||||
return _fake_training_result()
|
||||
|
||||
with patch("main.TRAINING_TOKEN", "secret-token"), \
|
||||
patch("main._models_ready", True), \
|
||||
patch("main.asyncio.to_thread", side_effect=fake_to_thread):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/train",
|
||||
files={"file": ("training.zip", _minimal_zip(), "application/zip")},
|
||||
headers={"X-Training-Token": "secret-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert fresh_metrics.ocr_training_runs_total.labels(
|
||||
kind="recognition", outcome="success"
|
||||
)._value.get() == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_training_runs_total_incremented_with_recognition_error_label(fresh_metrics):
|
||||
"""When ketos exits non-zero, the error counter bumps and the exception propagates.
|
||||
|
||||
Uses the narrowest available seam — `subprocess.run` returning a failing
|
||||
CompletedProcess — instead of stubbing the asyncio.to_thread boundary,
|
||||
so the test exercises the real _run_training error path.
|
||||
"""
|
||||
from subprocess import CompletedProcess
|
||||
|
||||
failing_proc = CompletedProcess(
|
||||
args=["ketos"], returncode=1, stdout="", stderr="synthetic ketos failure"
|
||||
)
|
||||
|
||||
with patch("main.TRAINING_TOKEN", "secret-token"), \
|
||||
patch("main._models_ready", True), \
|
||||
patch("main.subprocess.run", return_value=failing_proc):
|
||||
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/train",
|
||||
files={"file": ("training.zip", _minimal_zip(), "application/zip")},
|
||||
headers={"X-Training-Token": "secret-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert fresh_metrics.ocr_training_runs_total.labels(
|
||||
kind="recognition", outcome="error"
|
||||
)._value.get() == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_training_runs_total_incremented_with_segmentation_success_label(fresh_metrics):
|
||||
"""/segtrain success increments ocr_training_runs_total{kind=segmentation, outcome=success}."""
|
||||
async def fake_to_thread(func, *args, **kwargs):
|
||||
return _fake_training_result(accuracy=0.83)
|
||||
|
||||
with patch("main.TRAINING_TOKEN", "secret-token"), \
|
||||
patch("main._models_ready", True), \
|
||||
patch("main.asyncio.to_thread", side_effect=fake_to_thread):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/segtrain",
|
||||
files={"file": ("training.zip", _minimal_zip(), "application/zip")},
|
||||
headers={"X-Training-Token": "secret-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert fresh_metrics.ocr_training_runs_total.labels(
|
||||
kind="segmentation", outcome="success"
|
||||
)._value.get() == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_training_runs_total_incremented_with_recognition_success_label_for_train_sender(fresh_metrics):
|
||||
"""/train-sender success increments ocr_training_runs_total{kind=recognition, outcome=success}."""
|
||||
async def fake_to_thread(func, *args, **kwargs):
|
||||
return _fake_training_result()
|
||||
|
||||
with patch("main.TRAINING_TOKEN", "secret-token"), \
|
||||
patch("main._models_ready", True), \
|
||||
patch("main.asyncio.to_thread", side_effect=fake_to_thread):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/train-sender",
|
||||
files={"file": ("training.zip", _minimal_zip(), "application/zip")},
|
||||
data={"output_model_path": "/app/models/sender_test.mlmodel"},
|
||||
headers={"X-Training-Token": "secret-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert fresh_metrics.ocr_training_runs_total.labels(
|
||||
kind="recognition", outcome="success"
|
||||
)._value.get() == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_model_accuracy_gauge_stays_default_when_training_returns_no_accuracy(fresh_metrics):
|
||||
"""When the runner returns accuracy=None, ocr_model_accuracy must remain at its default 0."""
|
||||
async def fake_to_thread(func, *args, **kwargs):
|
||||
return {"loss": None, "accuracy": None, "cer": None, "epochs": 5}
|
||||
|
||||
with patch("main.TRAINING_TOKEN", "secret-token"), \
|
||||
patch("main._models_ready", True), \
|
||||
patch("main.asyncio.to_thread", side_effect=fake_to_thread):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/train",
|
||||
files={"file": ("training.zip", _minimal_zip(), "application/zip")},
|
||||
headers={"X-Training-Token": "secret-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Gauge was never .set() — accessing the label child still creates it with default 0.0.
|
||||
assert fresh_metrics.ocr_model_accuracy.labels(
|
||||
kind="recognition"
|
||||
)._value.get() == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_model_accuracy_gauge_set_per_kind_after_successful_training(fresh_metrics):
|
||||
"""After /train and /segtrain succeed, ocr_model_accuracy{kind=...} reflects the result."""
|
||||
recognition_accuracy = 0.917
|
||||
segmentation_accuracy = 0.834
|
||||
|
||||
async def fake_recognition_to_thread(func, *args, **kwargs):
|
||||
return _fake_training_result(accuracy=recognition_accuracy)
|
||||
|
||||
async def fake_segmentation_to_thread(func, *args, **kwargs):
|
||||
return _fake_training_result(accuracy=segmentation_accuracy)
|
||||
|
||||
with patch("main.TRAINING_TOKEN", "secret-token"), \
|
||||
patch("main._models_ready", True):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
with patch("main.asyncio.to_thread", side_effect=fake_recognition_to_thread):
|
||||
rec_resp = await client.post(
|
||||
"/train",
|
||||
files={"file": ("training.zip", _minimal_zip(), "application/zip")},
|
||||
headers={"X-Training-Token": "secret-token"},
|
||||
)
|
||||
assert rec_resp.status_code == 200
|
||||
with patch("main.asyncio.to_thread", side_effect=fake_segmentation_to_thread):
|
||||
seg_resp = await client.post(
|
||||
"/segtrain",
|
||||
files={"file": ("training.zip", _minimal_zip(), "application/zip")},
|
||||
headers={"X-Training-Token": "secret-token"},
|
||||
)
|
||||
assert seg_resp.status_code == 200
|
||||
|
||||
assert fresh_metrics.ocr_model_accuracy.labels(kind="recognition")._value.get() == pytest.approx(recognition_accuracy)
|
||||
assert fresh_metrics.ocr_model_accuracy.labels(kind="segmentation")._value.get() == pytest.approx(segmentation_accuracy)
|
||||
|
||||
|
||||
def test_ocr_models_ready_gauge_defaults_to_zero():
|
||||
"""A freshly-built OcrMetrics has ocr_models_ready=0 before lifespan runs."""
|
||||
metrics = build_metrics(CollectorRegistry())
|
||||
assert metrics.ocr_models_ready._value.get() == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_models_ready_gauge_is_one_after_lifespan_startup(fresh_metrics):
|
||||
"""The lifespan flips ocr_models_ready to 1 once load_models / load_spell_checker return.
|
||||
|
||||
ASGITransport does not run lifespan by default, so the lifespan context
|
||||
manager is driven directly to exercise the startup code path.
|
||||
"""
|
||||
assert fresh_metrics.ocr_models_ready._value.get() == 0.0
|
||||
with patch("main.kraken_engine.load_models"), \
|
||||
patch("main.load_spell_checker"):
|
||||
async with app.router.lifespan_context(app):
|
||||
assert fresh_metrics.ocr_models_ready._value.get() == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_processing_seconds_histogram_observed_per_page_in_guided_stream(fresh_metrics):
|
||||
"""The guided streaming generator observes ocr_processing_seconds once per page."""
|
||||
mock_images = [Image.new("RGB", (100, 100)) for _ in range(2)]
|
||||
regions = [
|
||||
{"pageNumber": 1, "x": 0.0, "y": 0.0, "width": 0.5, "height": 0.5, "annotationId": "a1"},
|
||||
{"pageNumber": 2, "x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0, "annotationId": "a2"},
|
||||
]
|
||||
|
||||
with patch("main._download_and_convert_pdf", new_callable=AsyncMock, return_value=mock_images), \
|
||||
patch("main.preprocess_page", side_effect=lambda img: img), \
|
||||
patch("main.surya_engine.extract_region_text", return_value="text"):
|
||||
async with ocr_client() as client:
|
||||
async with client.stream("POST", "/ocr/stream", json={
|
||||
"pdfUrl": "http://minio/doc.pdf",
|
||||
"scriptType": "TYPEWRITER",
|
||||
"language": "de",
|
||||
"regions": regions,
|
||||
}) as response:
|
||||
assert response.status_code == 200
|
||||
async for _ in response.aiter_lines():
|
||||
pass
|
||||
|
||||
sum_seconds, count = _histogram_count_sum(
|
||||
fresh_metrics.ocr_processing_seconds, engine="surya"
|
||||
)
|
||||
assert count == 2.0
|
||||
assert sum_seconds >= 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_processing_seconds_histogram_excludes_spell_check_time_in_guided_stream(fresh_metrics):
|
||||
"""The guided observation must time engine work only, not the spell-check pass.
|
||||
|
||||
Wall-clock bound rather than a structural `patch("main.time.monotonic")`:
|
||||
the patched attribute is the *global* `time.monotonic`, which httpx and
|
||||
asyncio also consume — they exhaust the deterministic sequence before the
|
||||
request reaches the engine loop. Bound is sized against the failure mode,
|
||||
not the noise floor: spell-check sleeps 0.05s × 2 regions = 0.1s, so a
|
||||
timer that accidentally wrapped `correct_text` would observe >= 0.1s. The
|
||||
0.09s ceiling catches that bug while leaving ~90ms of slack for slow CI
|
||||
runners (engine work is instantaneous under the mock).
|
||||
"""
|
||||
mock_images = [Image.new("RGB", (100, 100))]
|
||||
regions = [
|
||||
{"pageNumber": 1, "x": 0.0, "y": 0.0, "width": 0.5, "height": 0.5, "annotationId": "a1"},
|
||||
{"pageNumber": 1, "x": 0.5, "y": 0.0, "width": 0.5, "height": 0.5, "annotationId": "a2"},
|
||||
]
|
||||
|
||||
def slow_correct(text):
|
||||
import time as _time
|
||||
_time.sleep(0.05)
|
||||
return text
|
||||
|
||||
with patch("main._download_and_convert_pdf", new_callable=AsyncMock, return_value=mock_images), \
|
||||
patch("main.preprocess_page", side_effect=lambda img: img), \
|
||||
patch("main.kraken_engine.is_available", return_value=True), \
|
||||
patch("main.kraken_engine.extract_region_text", return_value="text"), \
|
||||
patch("main.correct_text", side_effect=slow_correct):
|
||||
async with ocr_client() as client:
|
||||
async with client.stream("POST", "/ocr/stream", json={
|
||||
"pdfUrl": "http://minio/doc.pdf",
|
||||
"scriptType": "HANDWRITING_KURRENT",
|
||||
"language": "de",
|
||||
"regions": regions,
|
||||
}) as response:
|
||||
assert response.status_code == 200
|
||||
async for _ in response.aiter_lines():
|
||||
pass
|
||||
|
||||
sum_seconds, _ = _histogram_count_sum(
|
||||
fresh_metrics.ocr_processing_seconds, engine="kraken"
|
||||
)
|
||||
assert sum_seconds < 0.09, f"timing must exclude spell-check; got sum={sum_seconds}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_jobs_total_not_incremented_when_pdf_download_fails_in_stream(fresh_metrics):
|
||||
"""If `_download_and_convert_pdf` raises, ocr_jobs_total is NOT incremented.
|
||||
|
||||
Mirrors the /ocr endpoint's semantics: the counter only records jobs that
|
||||
actually started OCR work, not failed downloads.
|
||||
"""
|
||||
async def fail_download(url):
|
||||
raise RuntimeError("synthetic download failure")
|
||||
|
||||
with patch("main._download_and_convert_pdf", new=fail_download):
|
||||
async with ocr_client(raise_app_exceptions=False) as client:
|
||||
response = await client.post("/ocr/stream", json={
|
||||
"pdfUrl": "http://minio/doc.pdf",
|
||||
"scriptType": "TYPEWRITER",
|
||||
"language": "de",
|
||||
})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert fresh_metrics.ocr_jobs_total.labels(
|
||||
engine="surya", script_type="TYPEWRITER"
|
||||
)._value.get() == 0.0
|
||||
|
||||
|
||||
def test_uvicorn_access_log_filter_fails_open_on_short_or_missing_args():
|
||||
"""The filter must default-allow records when args is None or shorter than expected.
|
||||
|
||||
Locks in fail-open behavior: if uvicorn ever changes its format we keep
|
||||
forwarding records to the handler rather than silently dropping logs.
|
||||
"""
|
||||
import logging as _logging
|
||||
from main import MetricsPathFilter
|
||||
|
||||
filt = MetricsPathFilter()
|
||||
none_record = _logging.LogRecord(
|
||||
name="uvicorn.access", level=_logging.INFO, pathname="", lineno=0,
|
||||
msg="some message", args=None, exc_info=None,
|
||||
)
|
||||
short_record = _logging.LogRecord(
|
||||
name="uvicorn.access", level=_logging.INFO, pathname="", lineno=0,
|
||||
msg="%s %s", args=("a", "b"), exc_info=None,
|
||||
)
|
||||
|
||||
assert filt.filter(none_record) is True
|
||||
assert filt.filter(short_record) is True
|
||||
|
||||
|
||||
def test_uvicorn_access_log_filter_skips_metrics_path():
|
||||
"""The MetricsPathFilter drops uvicorn.access log records that target /metrics."""
|
||||
import logging as _logging
|
||||
from main import MetricsPathFilter
|
||||
|
||||
filt = MetricsPathFilter()
|
||||
metrics_record = _logging.LogRecord(
|
||||
name="uvicorn.access", level=_logging.INFO, pathname="", lineno=0,
|
||||
msg='%s - "%s %s HTTP/%s" %d',
|
||||
args=("127.0.0.1:1234", "GET", "/metrics", "1.1", 200),
|
||||
exc_info=None,
|
||||
)
|
||||
health_record = _logging.LogRecord(
|
||||
name="uvicorn.access", level=_logging.INFO, pathname="", lineno=0,
|
||||
msg='%s - "%s %s HTTP/%s" %d',
|
||||
args=("127.0.0.1:1234", "GET", "/health", "1.1", 200),
|
||||
exc_info=None,
|
||||
)
|
||||
ocr_record = _logging.LogRecord(
|
||||
name="uvicorn.access", level=_logging.INFO, pathname="", lineno=0,
|
||||
msg='%s - "%s %s HTTP/%s" %d',
|
||||
args=("127.0.0.1:1234", "POST", "/ocr", "1.1", 200),
|
||||
exc_info=None,
|
||||
)
|
||||
|
||||
assert filt.filter(metrics_record) is False
|
||||
assert filt.filter(health_record) is False
|
||||
assert filt.filter(ocr_record) is True
|
||||
Reference in New Issue
Block a user