Compare commits

..

9 Commits

Author SHA1 Message Date
Marcel
3a0290ab35 ci(observability): inject GRAFANA_DB_PASSWORD from Gitea secrets
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m29s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m34s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
Wires the new GRAFANA_DB_PASSWORD secret through the deploy pipeline:

- docker-compose.prod.yml: backend env now passes GRAFANA_DB_PASSWORD
  through so Flyway V68 can resolve the ${grafanaDbPassword} placeholder
  in production and staging (it already worked in local dev via
  docker-compose.yml).
- release.yml + nightly.yml: declare GRAFANA_DB_PASSWORD as a required
  Gitea secret, write it into .env.production / .env.staging (consumed
  by archive-backend), and into /opt/familienarchiv/obs-secrets.env
  (consumed by obs-grafana's PostgreSQL datasource).

Operator action before the next deploy: add a GRAFANA_DB_PASSWORD value
to the Gitea repo secrets (openssl rand -hex 32).

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:44:19 +02:00
Marcel
5d191b2286 docs(architecture): show Grafana→PostgreSQL link for PO Overview dashboard
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m31s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m32s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Adds the new read-only connection from Grafana to archive-db (via the
grafana_reader role) introduced by the PO Overview dashboard.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:27:06 +02:00
Marcel
2fa1ce3eb8 docs(deployment): document GRAFANA_DB_PASSWORD across env tables
Adds GRAFANA_DB_PASSWORD to the observability-stack env-var table, the
Gitea secrets table, and the obs-secrets.env reference, so operators see
the variable wherever they look for related secrets.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:26:34 +02:00
Marcel
99c9612a35 feat(observability): add PO Overview Grafana dashboard
Provisioned dashboard for the product owner's weekly check-in: system
health (Prometheus + Loki), user activity (PostgreSQL audit_log), archive
progress (PostgreSQL transcription_blocks + audit_log), and OCR quality
(Prometheus ocr-service metrics). Default range 7d, manual refresh,
thresholds per the issue spec.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:25:41 +02:00
Marcel
93eed612bc chore(observability): document GRAFANA_DB_PASSWORD in env files
.env.example: declare GRAFANA_DB_PASSWORD with an openssl rand -hex 32 hint
so a missing value fails loudly (NFR-OPS-02). obs.env: add a comment
explaining that the real value comes from CI's obs-secrets.env, matching
the pattern used for other secrets in that file.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:23:09 +02:00
Marcel
336ef20bd9 feat(observability): provision Grafana PostgreSQL datasource
Adds a read-only datasource pointing at archive-db using the grafana_reader
role (provisioned by Flyway V68). The password is interpolated from the
GRAFANA_DB_PASSWORD env var passed to obs-grafana, and the connection is
locked to editable: false so the credential cannot be inspected via the UI.

sslmode=disable is intentional: traffic stays inside archiv-net.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:22:35 +02:00
Marcel
1564ffeaad feat(observability): pass GRAFANA_DB_PASSWORD to archive-backend
Flyway runs inside the backend container at startup; V68's
${grafanaDbPassword} placeholder is resolved from this env var.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:22:02 +02:00
Marcel
f9d4d9a252 feat(observability): wire obs-grafana to archive-db and inject GRAFANA_DB_PASSWORD
obs-grafana now joins archiv-net so it can resolve archive-db:5432 for the
PO Overview dashboard's PostgreSQL datasource, and receives GRAFANA_DB_PASSWORD
so provisioning can interpolate it into the datasource config.

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:21:25 +02:00
Marcel
f0b801f171 feat(observability): create grafana_reader read-only DB role
Add Flyway V68 migration that provisions a read-only PostgreSQL role
scoped to audit_log, documents, and transcription_blocks. The role's
password is injected via the new ${grafanaDbPassword} Flyway placeholder,
which FlywayConfig reads from the GRAFANA_DB_PASSWORD env var. The
migration is idempotent: CREATE on first run, ALTER on re-run.

Adds a Testcontainers integration test asserting positive grants on the
three intended tables and a negative grant on app_users (NFR-SEC-01).

Refs #651.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 19:20:39 +02:00
134 changed files with 1593 additions and 2389 deletions

View File

@@ -39,6 +39,12 @@ PORT_PROMETHEUS=9090
# Grafana admin password — change this before exposing Grafana beyond localhost # Grafana admin password — change this before exposing Grafana beyond localhost
GRAFANA_ADMIN_PASSWORD=changeme 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 — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
GLITCHTIP_DOMAIN=http://localhost:3002 GLITCHTIP_DOMAIN=http://localhost:3002

View File

@@ -31,6 +31,7 @@ name: nightly
# STAGING_APP_ADMIN_USERNAME # STAGING_APP_ADMIN_USERNAME
# STAGING_APP_ADMIN_PASSWORD # STAGING_APP_ADMIN_PASSWORD
# GRAFANA_ADMIN_PASSWORD # GRAFANA_ADMIN_PASSWORD
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
# GLITCHTIP_SECRET_KEY # GLITCHTIP_SECRET_KEY
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled) # SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
@@ -79,7 +80,7 @@ jobs:
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
POSTGRES_USER=archiv POSTGRES_USER=archiv
SENTRY_DSN=${{ secrets.SENTRY_DSN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }}
VITE_SENTRY_DSN=${{ secrets.VITE_SENTRY_DSN }} GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
EOF EOF
- name: Verify backend /import:ro mount is wired - name: Verify backend /import:ro mount is wired
@@ -143,6 +144,7 @@ jobs:
cp docker-compose.observability.yml /opt/familienarchiv/ cp docker-compose.observability.yml /opt/familienarchiv/
cat > /opt/familienarchiv/obs-secrets.env <<'EOF' cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }} GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }} POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
POSTGRES_HOST=archiv-staging-db-1 POSTGRES_HOST=archiv-staging-db-1

View File

@@ -35,6 +35,7 @@ name: release
# MAIL_USERNAME # MAIL_USERNAME
# MAIL_PASSWORD # MAIL_PASSWORD
# GRAFANA_ADMIN_PASSWORD # GRAFANA_ADMIN_PASSWORD
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
# GLITCHTIP_SECRET_KEY # GLITCHTIP_SECRET_KEY
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled) # SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
@@ -77,6 +78,7 @@ jobs:
IMPORT_HOST_DIR=/srv/familienarchiv-production/import IMPORT_HOST_DIR=/srv/familienarchiv-production/import
POSTGRES_USER=archiv POSTGRES_USER=archiv
SENTRY_DSN=${{ secrets.SENTRY_DSN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
EOF EOF
- name: Build images - name: Build images
@@ -110,6 +112,7 @@ jobs:
cp docker-compose.observability.yml /opt/familienarchiv/ cp docker-compose.observability.yml /opt/familienarchiv/
cat > /opt/familienarchiv/obs-secrets.env <<'EOF' cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }} GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }} POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
POSTGRES_HOST=archiv-production-db-1 POSTGRES_HOST=archiv-production-db-1

View File

@@ -7,12 +7,15 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource; import javax.sql.DataSource;
import java.util.Map;
@Configuration @Configuration
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class FlywayConfig { public class FlywayConfig {
private static final String GRAFANA_DB_PASSWORD_FALLBACK = "changeme-grafana-db-password";
private final DataSource dataSource; private final DataSource dataSource;
@Bean(name = "flyway") @Bean(name = "flyway")
@@ -21,6 +24,7 @@ public class FlywayConfig {
Flyway flyway = Flyway.configure() Flyway flyway = Flyway.configure()
.dataSource(dataSource) .dataSource(dataSource)
.locations("classpath:db/migration") .locations("classpath:db/migration")
.placeholders(Map.of("grafanaDbPassword", resolveGrafanaDbPassword()))
.baselineOnMigrate(true) .baselineOnMigrate(true)
.baselineVersion("4") .baselineVersion("4")
.load(); .load();
@@ -28,4 +32,14 @@ public class FlywayConfig {
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted); log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
return flyway; 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;
}
} }

View File

@@ -25,14 +25,11 @@ import java.util.UUID;
@NamedEntityGraph(name = "Document.full", attributeNodes = { @NamedEntityGraph(name = "Document.full", attributeNodes = {
@NamedAttributeNode("sender"), @NamedAttributeNode("sender"),
@NamedAttributeNode("receivers"), @NamedAttributeNode("receivers"),
@NamedAttributeNode("tags"), @NamedAttributeNode("tags")
@NamedAttributeNode("trainingLabels")
}) })
@NamedEntityGraph(name = "Document.list", attributeNodes = { @NamedEntityGraph(name = "Document.list", attributeNodes = {
@NamedAttributeNode("sender"), @NamedAttributeNode("sender"),
@NamedAttributeNode("receivers"), @NamedAttributeNode("tags")
@NamedAttributeNode("tags"),
@NamedAttributeNode("trainingLabels")
}) })
@Entity @Entity
@Table(name = "documents") @Table(name = "documents")

View File

@@ -43,7 +43,7 @@ public class TranscriptionBlockController {
@PostMapping @PostMapping
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock createBlock( public TranscriptionBlock createBlock(
@PathVariable UUID documentId, @PathVariable UUID documentId,
@Valid @RequestBody CreateTranscriptionBlockDTO dto, @Valid @RequestBody CreateTranscriptionBlockDTO dto,
@@ -53,7 +53,7 @@ public class TranscriptionBlockController {
} }
@PutMapping("/{blockId}") @PutMapping("/{blockId}")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock updateBlock( public TranscriptionBlock updateBlock(
@PathVariable UUID documentId, @PathVariable UUID documentId,
@PathVariable UUID blockId, @PathVariable UUID blockId,
@@ -65,7 +65,7 @@ public class TranscriptionBlockController {
@DeleteMapping("/{blockId}") @DeleteMapping("/{blockId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public void deleteBlock( public void deleteBlock(
@PathVariable UUID documentId, @PathVariable UUID documentId,
@PathVariable UUID blockId) { @PathVariable UUID blockId) {
@@ -73,7 +73,7 @@ public class TranscriptionBlockController {
} }
@PutMapping("/reorder") @PutMapping("/reorder")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> reorderBlocks( public List<TranscriptionBlock> reorderBlocks(
@PathVariable UUID documentId, @PathVariable UUID documentId,
@RequestBody ReorderTranscriptionBlocksDTO dto) { @RequestBody ReorderTranscriptionBlocksDTO dto) {
@@ -82,7 +82,7 @@ public class TranscriptionBlockController {
} }
@PutMapping("/{blockId}/review") @PutMapping("/{blockId}/review")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public TranscriptionBlock reviewBlock( public TranscriptionBlock reviewBlock(
@PathVariable UUID documentId, @PathVariable UUID documentId,
@PathVariable UUID blockId, @PathVariable UUID blockId,
@@ -92,7 +92,7 @@ public class TranscriptionBlockController {
} }
@PutMapping("/review-all") @PutMapping("/review-all")
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) @RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> markAllBlocksReviewed( public List<TranscriptionBlock> markAllBlocksReviewed(
@PathVariable UUID documentId, @PathVariable UUID documentId,
Authentication authentication) { Authentication authentication) {

View File

@@ -56,17 +56,9 @@ public class MassImportService {
public enum State { IDLE, RUNNING, DONE, FAILED } public enum State { IDLE, RUNNING, DONE, FAILED }
public enum SkipReason {
INVALID_FILENAME_PATH_TRAVERSAL,
INVALID_PDF_SIGNATURE,
FILE_READ_ERROR,
ALREADY_EXISTS,
S3_UPLOAD_FAILED
}
public record SkippedFile( public record SkippedFile(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) SkipReason reason @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String reason
) {} ) {}
public record ImportStatus( public record ImportStatus(
@@ -299,11 +291,6 @@ public class MassImportService {
if (index.isBlank()) continue; if (index.isBlank()) continue;
String filename = index.contains(".") ? index : index + ".pdf"; String filename = index.contains(".") ? index : index + ".pdf";
if (!isValidImportFilename(filename)) {
log.warn("Skipping import row {}: filename rejected — {}", i, filename);
skippedFiles.add(new SkippedFile(filename, SkipReason.INVALID_FILENAME_PATH_TRAVERSAL));
continue;
}
Optional<File> fileOnDisk = findFileRecursive(filename); Optional<File> fileOnDisk = findFileRecursive(filename);
if (fileOnDisk.isEmpty()) { if (fileOnDisk.isEmpty()) {
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename); log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
@@ -313,17 +300,17 @@ public class MassImportService {
try { try {
if (!isPdfMagicBytes(fileOnDisk.get())) { if (!isPdfMagicBytes(fileOnDisk.get())) {
log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename); log.warn("Überspringe {}: Datei beginnt nicht mit %PDF-Signatur", filename);
skippedFiles.add(new SkippedFile(filename, SkipReason.INVALID_PDF_SIGNATURE)); skippedFiles.add(new SkippedFile(filename, "INVALID_PDF_SIGNATURE"));
continue; continue;
} }
} catch (IOException e) { } catch (IOException e) {
log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e); log.error("Fehler beim Prüfen der Magic-Bytes für {}", filename, e);
skippedFiles.add(new SkippedFile(filename, SkipReason.FILE_READ_ERROR)); skippedFiles.add(new SkippedFile(filename, "FILE_READ_ERROR"));
continue; continue;
} }
} }
Optional<SkipReason> skipReason = importSingleDocument(cells, fileOnDisk, filename, index); Optional<String> skipReason = importSingleDocument(cells, fileOnDisk, filename, index);
if (skipReason.isPresent()) { if (skipReason.isPresent()) {
skippedFiles.add(new SkippedFile(filename, skipReason.get())); skippedFiles.add(new SkippedFile(filename, skipReason.get()));
} else { } else {
@@ -333,23 +320,6 @@ public class MassImportService {
return new ProcessResult(processed, skippedFiles); return new ProcessResult(processed, skippedFiles);
} }
private boolean isValidImportFilename(String filename) {
if (filename == null || filename.isBlank()) return false;
if (filename.contains("/")) return false;
if (filename.contains("\\")) return false;
if (filename.contains("")) return false; // U+2215 DIVISION SLASH
if (filename.contains("")) return false; // U+FF0F FULLWIDTH SOLIDUS
if (filename.contains("")) return false; // U+29F5 REVERSE SOLIDUS OPERATOR
if (filename.contains("..")) return false;
if (filename.equals(".")) return false;
if (filename.contains("\0")) return false;
// Paths.get() is safe here on Linux for all inputs that passed the checks above;
// it may throw InvalidPathException for OS-specific illegal chars on Windows,
// but those are not reachable in production.
if (Paths.get(filename).isAbsolute()) return false;
return true;
}
// package-private: Mockito spy in tests can override to inject IOException // package-private: Mockito spy in tests can override to inject IOException
InputStream openFileStream(File file) throws IOException { InputStream openFileStream(File file) throws IOException {
return new FileInputStream(file); return new FileInputStream(file);
@@ -372,11 +342,11 @@ public class MassImportService {
* @return empty Optional on success; an Optional containing the skip reason on failure/skip. * @return empty Optional on success; an Optional containing the skip reason on failure/skip.
*/ */
@Transactional @Transactional
protected Optional<SkipReason> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) { protected Optional<String> importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename); Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) { if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
log.info("Dokument {} existiert bereits, überspringe.", originalFilename); log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
return Optional.of(SkipReason.ALREADY_EXISTS); return Optional.of("ALREADY_EXISTS");
} }
String archiveBox = getCell(cells, colBox); String archiveBox = getCell(cells, colBox);
@@ -412,7 +382,7 @@ public class MassImportService {
status = DocumentStatus.UPLOADED; status = DocumentStatus.UPLOADED;
} catch (Exception e) { } catch (Exception e) {
log.error("S3 Upload Fehler für {}", file.get().getName(), e); log.error("S3 Upload Fehler für {}", file.get().getName(), e);
return Optional.of(SkipReason.S3_UPLOAD_FAILED); return Optional.of("S3_UPLOAD_FAILED");
} }
} }
@@ -490,18 +460,11 @@ public class MassImportService {
} }
private Optional<File> findFileRecursive(String filename) { private Optional<File> findFileRecursive(String filename) {
File baseDir = new File(importDir); try (Stream<Path> walk = Files.walk(Paths.get(importDir))) {
try (Stream<Path> walk = Files.walk(baseDir.toPath())) { return walk.filter(p -> !Files.isDirectory(p))
Optional<Path> match = walk.filter(p -> !Files.isDirectory(p))
.filter(p -> p.getFileName().toString().equals(filename)) .filter(p -> p.getFileName().toString().equals(filename))
.map(Path::toFile)
.findFirst(); .findFirst();
if (match.isEmpty()) return Optional.empty();
File candidate = match.get().toFile();
String baseDirCanonical = baseDir.getCanonicalPath();
if (!candidate.getCanonicalPath().startsWith(baseDirCanonical + File.separator)) {
throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Path escape detected: " + candidate);
}
return Optional.of(candidate);
} catch (IOException e) { } catch (IOException e) {
return Optional.empty(); return Optional.empty();
} }

View File

@@ -0,0 +1,17 @@
-- 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;

View File

@@ -0,0 +1,47 @@
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);
}
}

View File

@@ -154,10 +154,10 @@ class MassImportServiceTest {
.build(); .build();
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing)); when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
Optional<MassImportService.SkipReason> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001"); Optional<String> result = service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
verify(documentService, never()).save(any()); verify(documentService, never()).save(any());
assertThat(result).isPresent().contains(MassImportService.SkipReason.ALREADY_EXISTS); assertThat(result).isPresent().contains("ALREADY_EXISTS");
} }
// ─── importSingleDocument — already-exists guard fires before file I/O ───── // ─── importSingleDocument — already-exists guard fires before file I/O ─────
@@ -179,10 +179,10 @@ class MassImportServiceTest {
byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF- byte[] pdfHeader = {0x25, 0x50, 0x44, 0x46, 0x2D}; // %PDF-
Files.write(physicalFile, pdfHeader); Files.write(physicalFile, pdfHeader);
Optional<MassImportService.SkipReason> result = service.importSingleDocument( Optional<String> result = service.importSingleDocument(
minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present"); minimalCells("present.pdf"), Optional.of(physicalFile.toFile()), "present.pdf", "present");
assertThat(result).isPresent().contains(MassImportService.SkipReason.ALREADY_EXISTS); assertThat(result).isPresent().contains("ALREADY_EXISTS");
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
verify(documentService, never()).save(any()); verify(documentService, never()).save(any());
} }
@@ -204,7 +204,7 @@ class MassImportServiceTest {
assertThat(service.getStatus().skipped()).isEqualTo(1); assertThat(service.getStatus().skipped()).isEqualTo(1);
assertThat(service.getStatus().skippedFiles()) assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason) .extracting(MassImportService.SkippedFile::filename, MassImportService.SkippedFile::reason)
.containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", MassImportService.SkipReason.S3_UPLOAD_FAILED)); .containsExactly(org.assertj.core.groups.Tuple.tuple("upload_fail.pdf", "S3_UPLOAD_FAILED"));
} }
@Test @Test
@@ -223,7 +223,7 @@ class MassImportServiceTest {
assertThat(service.getStatus().skipped()).isEqualTo(1); assertThat(service.getStatus().skipped()).isEqualTo(1);
assertThat(service.getStatus().skippedFiles()) assertThat(service.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::reason) .extracting(MassImportService.SkippedFile::reason)
.containsExactly(MassImportService.SkipReason.ALREADY_EXISTS); .containsExactly("ALREADY_EXISTS");
} }
// ─── importSingleDocument — create new document (metadata only) ─────────── // ─── importSingleDocument — create new document (metadata only) ───────────
@@ -283,11 +283,11 @@ class MassImportServiceTest {
doThrow(new RuntimeException("S3 error")) doThrow(new RuntimeException("S3 error"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
Optional<MassImportService.SkipReason> result = service.importSingleDocument( Optional<String> result = service.importSingleDocument(
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail"); minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
verify(documentService, never()).save(any()); verify(documentService, never()).save(any());
assertThat(result).isPresent().contains(MassImportService.SkipReason.S3_UPLOAD_FAILED); assertThat(result).isPresent().contains("S3_UPLOAD_FAILED");
} }
// ─── importSingleDocument — sender handling ─────────────────────────────── // ─── importSingleDocument — sender handling ───────────────────────────────
@@ -438,110 +438,6 @@ class MassImportServiceTest {
verify(documentService).findByOriginalFilename("doc002.pdf"); verify(documentService).findByOriginalFilename("doc002.pdf");
} }
// ─── isValidImportFilename — security regression — do not remove ─────────
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsNull() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", (String) null);
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsBlank() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", " ");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsForwardSlash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "etc/passwd");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsBackslash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "..\\etc\\passwd");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsDotDot() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "doc..evil.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsDotDot() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "..");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameIsAbsolutePath() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "/etc/passwd");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsNullByte() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "file\0.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsTrue_whenFilenameIsPlainBasename() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "document.pdf");
assertThat(result).isTrue();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsUnicodeDivisionSlash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foobar.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsFullwidthSlash() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foobar.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsFalse_whenFilenameContainsUnicodeReverseSolidus() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "foobar.pdf");
assertThat(result).isFalse();
}
@Test
void isValidImportFilename_returnsTrue_whenFilenameHasLeadingDot() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", ".hidden.pdf");
assertThat(result).isTrue();
}
@Test
void isValidImportFilename_returnsTrue_whenFilenameHasSpaces() {
boolean result = ReflectionTestUtils.invokeMethod(service, "isValidImportFilename", "Brief an Oma.pdf");
assertThat(result).isTrue();
}
@Test
void processRows_skipsRowAndContinues_whenFilenameIsPathTraversal() {
when(documentService.findByOriginalFilename("legitimate.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<List<String>> rows = List.of(
List.of("header"),
minimalCells("../evil"), // row 1: path traversal — should be skipped
minimalCells("legitimate.pdf") // row 2: valid — should be processed
);
MassImportService.ProcessResult result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result.processed()).isEqualTo(1);
assertThat(result.skippedFiles())
.extracting(MassImportService.SkippedFile::reason)
.containsExactly(MassImportService.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
}
// ─── importSingleDocument — non-blank optional fields ──────────────────── // ─── importSingleDocument — non-blank optional fields ────────────────────
@Test @Test
@@ -755,22 +651,7 @@ class MassImportServiceTest {
assertThat(spyService.getStatus().skipped()).isEqualTo(1); assertThat(spyService.getStatus().skipped()).isEqualTo(1);
assertThat(spyService.getStatus().skippedFiles()) assertThat(spyService.getStatus().skippedFiles())
.extracting(MassImportService.SkippedFile::reason) .extracting(MassImportService.SkippedFile::reason)
.containsExactly(MassImportService.SkipReason.FILE_READ_ERROR); .containsExactly("FILE_READ_ERROR");
}
// ─── findFileRecursive — symlink escape security regression — do not remove ─
@Test
void findFileRecursive_throwsDomainException_whenSymlinkEscapesImportDir(
@TempDir Path importDirPath, @TempDir Path outsideDir) throws Exception {
Path outsideFile = outsideDir.resolve("secret.pdf");
Files.writeString(outsideFile, "sensitive content");
Files.createSymbolicLink(importDirPath.resolve("secret.pdf"), outsideFile);
ReflectionTestUtils.setField(service, "importDir", importDirPath.toString());
assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod(service, "findFileRecursive", "secret.pdf"))
.isInstanceOf(DomainException.class);
} }
// ─── readOds — XXE security regression ─────────────────────────────────── // ─── readOds — XXE security regression ───────────────────────────────────

View File

@@ -147,6 +147,9 @@ services:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme}
GF_USERS_ALLOW_SIGN_UP: "false" GF_USERS_ALLOW_SIGN_UP: "false"
GF_SERVER_ROOT_URL: ${GF_SERVER_ROOT_URL:-http://localhost:3003} 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: volumes:
- grafana_data:/var/lib/grafana - grafana_data:/var/lib/grafana
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro - ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
@@ -165,6 +168,7 @@ services:
condition: service_healthy condition: service_healthy
networks: networks:
- obs-net - obs-net
- archiv-net # PO Overview dashboard queries archive-db via the grafana_reader role
# --- Error Tracking: GlitchTip --- # --- Error Tracking: GlitchTip ---

View File

@@ -227,6 +227,9 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
SPRING_DATASOURCE_USERNAME: archiv SPRING_DATASOURCE_USERNAME: archiv
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} 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. # Application uses the bucket-scoped service account, not MinIO root.
S3_ENDPOINT: http://minio:9000 S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: archiv-app S3_ACCESS_KEY: archiv-app
@@ -252,8 +255,6 @@ services:
OTEL_METRICS_EXPORTER: none OTEL_METRICS_EXPORTER: none
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1} MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
SENTRY_DSN: ${SENTRY_DSN:-}
LOGGING_STRUCTURED_FORMAT_CONSOLE: ecs
networks: networks:
- archiv-net - archiv-net
healthcheck: healthcheck:
@@ -268,10 +269,6 @@ services:
build: build:
context: ./frontend context: ./frontend
target: production target: production
args:
# Vite build-time variable — baked into the JS bundle at build time.
# Empty default so deploys succeed before the secret is configured.
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
backend: backend:

View File

@@ -163,6 +163,9 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB} SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} 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_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: ${MINIO_ROOT_USER} S3_ACCESS_KEY: ${MINIO_ROOT_USER}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD} S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}

View File

@@ -152,6 +152,7 @@ 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` | — | — | | `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` | — | — | | `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_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` | — | — | | `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_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 | | `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` | — | YES | YES |
@@ -256,6 +257,7 @@ git.raddatz.cloud A <server IP>
| `MAIL_USERNAME` | release.yml | SMTP user | | `MAIL_USERNAME` | release.yml | SMTP user |
| `MAIL_PASSWORD` | release.yml | SMTP password | | `MAIL_PASSWORD` | release.yml | SMTP password |
| `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong 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` | | `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 | | `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 | | `VITE_SENTRY_DSN` | both | GlitchTip frontend project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
@@ -357,6 +359,7 @@ Both files are passed explicitly via `--env-file` to the compose command, so the
| Gitea secret | Notes | | Gitea secret | Notes |
|---|---| |---|---|
| `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release | | `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 | | `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release |
| `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container | | `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container |

View File

@@ -46,6 +46,7 @@ Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 431
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090") Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
Rel(grafana, loki, "Queries logs", "HTTP 3100") Rel(grafana, loki, "Queries logs", "HTTP 3100")
Rel(grafana, tempo, "Queries traces", "HTTP 3200") 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(glitchtip, db, "Stores error events in glitchtip DB", "PostgreSQL / archiv-net")
Rel(obs_glitchtip_worker, obs_redis, "Processes Celery tasks", "Redis / obs-net") Rel(obs_glitchtip_worker, obs_redis, "Processes Celery tasks", "Redis / obs-net")

View File

@@ -16,10 +16,6 @@ CMD ["npm", "run", "dev"]
# Compiles the SvelteKit Node-adapter output to /app/build. # Compiles the SvelteKit Node-adapter output to /app/build.
FROM node:20.19.0-alpine3.21 AS build FROM node:20.19.0-alpine3.21 AS build
WORKDIR /app WORKDIR /app
# VITE_SENTRY_DSN is a build-time variable — Vite bakes it into the bundle.
# Passed via docker-compose build.args; empty string disables the SDK.
ARG VITE_SENTRY_DSN
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .

View File

@@ -106,31 +106,6 @@ export default defineConfig(
] ]
} }
}, },
{
// Forbid test fixtures (*.test-fixture.svelte) from being imported by
// production code. Tree-shaking keeps them out of the production bundle
// today (no route reaches them), but a lint rule makes the boundary
// explicit so an accidental autocomplete import in a route or component
// fails fast. Test files (*.spec.ts / *.test.ts) and the fixtures
// themselves are exempt — see the next block. Nora #2 on PR #629
// round 3.
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js', '**/*.ts'],
ignores: ['**/*.spec.ts', '**/*.test.ts', '**/*.test-fixture.svelte'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['**/*.test-fixture.svelte'],
message:
'Test fixtures (*.test-fixture.svelte) are test-only — do not import from production code. Tracked by #637.'
}
]
}
]
}
},
{ {
plugins: { boundaries }, plugins: { boundaries },
settings: { settings: {

View File

@@ -445,12 +445,8 @@
"person_mention_load_error": "Person konnte nicht geladen werden.", "person_mention_load_error": "Person konnte nicht geladen werden.",
"person_mention_loading": "Lade Person…", "person_mention_loading": "Lade Person…",
"person_mention_popup_empty": "Keine Personen gefunden", "person_mention_popup_empty": "Keine Personen gefunden",
"person_mention_search_label": "Person suchen",
"person_mention_search_prompt": "Namen eingeben…",
"person_mention_btn_label": "Person verlinken", "person_mention_btn_label": "Person verlinken",
"person_mention_create_new": "Neue Person anlegen", "person_mention_create_new": "Neue Person anlegen",
"person_mention_results_count_singular": "1 Person gefunden",
"person_mention_results_count_plural": "{count} Personen gefunden",
"transcription_editor_aria_label": "Transkriptionstext", "transcription_editor_aria_label": "Transkriptionstext",
"person_born_name_prefix": "geb.", "person_born_name_prefix": "geb.",
"page_title_home": "Archiv", "page_title_home": "Archiv",
@@ -526,7 +522,6 @@
"notification_filter_unread": "Ungelesen", "notification_filter_unread": "Ungelesen",
"notification_filter_mention": "Erwähnung", "notification_filter_mention": "Erwähnung",
"notification_filter_reply": "Antwort", "notification_filter_reply": "Antwort",
"notification_error_generic": "Aktion fehlgeschlagen. Bitte versuche es erneut.",
"notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren", "notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren",
"notification_load_more": "Ältere laden", "notification_load_more": "Ältere laden",
"notification_empty_history": "Keine Benachrichtigungen", "notification_empty_history": "Keine Benachrichtigungen",
@@ -638,9 +633,6 @@
"transcription_block_review": "Als geprüft markieren", "transcription_block_review": "Als geprüft markieren",
"transcription_block_unreview": "Markierung aufheben", "transcription_block_unreview": "Markierung aufheben",
"transcription_reviewed_count": "{reviewed} von {total} geprüft", "transcription_reviewed_count": "{reviewed} von {total} geprüft",
"transcription_mark_all_reviewed": "Alle als fertig markieren",
"transcription_mark_all_reviewed_disabled": "Alle Blöcke sind bereits als fertig markiert",
"transcription_mark_all_reviewed_error": "Markierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"training_ocr_heading": "Kurrent-Erkennung trainieren", "training_ocr_heading": "Kurrent-Erkennung trainieren",
"training_ocr_description": "Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für Kurrentschrift zu verbessern.", "training_ocr_description": "Starte ein neues Training mit den bisher geprüften OCR-Blöcken, um die Erkennungsgenauigkeit für Kurrentschrift zu verbessern.",
"training_ocr_blocks_ready": "{blocks} geprüfte Blöcke bereit / {docs} Dokumente", "training_ocr_blocks_ready": "{blocks} geprüfte Blöcke bereit / {docs} Dokumente",

View File

@@ -445,12 +445,8 @@
"person_mention_load_error": "Could not load person.", "person_mention_load_error": "Could not load person.",
"person_mention_loading": "Loading person…", "person_mention_loading": "Loading person…",
"person_mention_popup_empty": "No persons found", "person_mention_popup_empty": "No persons found",
"person_mention_search_label": "Search for a person",
"person_mention_search_prompt": "Enter a name…",
"person_mention_btn_label": "Link person", "person_mention_btn_label": "Link person",
"person_mention_create_new": "Create new person", "person_mention_create_new": "Create new person",
"person_mention_results_count_singular": "1 person found",
"person_mention_results_count_plural": "{count} persons found",
"transcription_editor_aria_label": "Transcription text", "transcription_editor_aria_label": "Transcription text",
"person_born_name_prefix": "née", "person_born_name_prefix": "née",
"page_title_home": "Archive", "page_title_home": "Archive",
@@ -526,7 +522,6 @@
"notification_filter_unread": "Unread", "notification_filter_unread": "Unread",
"notification_filter_mention": "Mention", "notification_filter_mention": "Mention",
"notification_filter_reply": "Reply", "notification_filter_reply": "Reply",
"notification_error_generic": "Action failed. Please try again.",
"notification_mark_all_read_aria": "Mark all notifications as read", "notification_mark_all_read_aria": "Mark all notifications as read",
"notification_load_more": "Load older", "notification_load_more": "Load older",
"notification_empty_history": "No notifications", "notification_empty_history": "No notifications",
@@ -638,9 +633,6 @@
"transcription_block_review": "Mark as reviewed", "transcription_block_review": "Mark as reviewed",
"transcription_block_unreview": "Unmark as reviewed", "transcription_block_unreview": "Unmark as reviewed",
"transcription_reviewed_count": "{reviewed} of {total} reviewed", "transcription_reviewed_count": "{reviewed} of {total} reviewed",
"transcription_mark_all_reviewed": "Mark all as reviewed",
"transcription_mark_all_reviewed_disabled": "All blocks are already marked as reviewed",
"transcription_mark_all_reviewed_error": "Failed to mark all as reviewed. Please try again.",
"training_ocr_heading": "Train Kurrent recognition", "training_ocr_heading": "Train Kurrent recognition",
"training_ocr_description": "Start a new training run using the reviewed OCR blocks to improve recognition accuracy for Kurrent script.", "training_ocr_description": "Start a new training run using the reviewed OCR blocks to improve recognition accuracy for Kurrent script.",
"training_ocr_blocks_ready": "{blocks} reviewed blocks ready / {docs} documents", "training_ocr_blocks_ready": "{blocks} reviewed blocks ready / {docs} documents",

View File

@@ -445,12 +445,8 @@
"person_mention_load_error": "No se pudo cargar la persona.", "person_mention_load_error": "No se pudo cargar la persona.",
"person_mention_loading": "Cargando persona…", "person_mention_loading": "Cargando persona…",
"person_mention_popup_empty": "No se encontraron personas", "person_mention_popup_empty": "No se encontraron personas",
"person_mention_search_label": "Buscar persona",
"person_mention_search_prompt": "Escribe un nombre…",
"person_mention_btn_label": "Vincular persona", "person_mention_btn_label": "Vincular persona",
"person_mention_create_new": "Crear nueva persona", "person_mention_create_new": "Crear nueva persona",
"person_mention_results_count_singular": "1 persona encontrada",
"person_mention_results_count_plural": "{count} personas encontradas",
"transcription_editor_aria_label": "Texto de transcripción", "transcription_editor_aria_label": "Texto de transcripción",
"person_born_name_prefix": "n.", "person_born_name_prefix": "n.",
"page_title_home": "Archivo", "page_title_home": "Archivo",
@@ -526,7 +522,6 @@
"notification_filter_unread": "No leídas", "notification_filter_unread": "No leídas",
"notification_filter_mention": "Mención", "notification_filter_mention": "Mención",
"notification_filter_reply": "Respuesta", "notification_filter_reply": "Respuesta",
"notification_error_generic": "La acción ha fallado. Por favor, inténtalo de nuevo.",
"notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas", "notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas",
"notification_load_more": "Cargar anteriores", "notification_load_more": "Cargar anteriores",
"notification_empty_history": "Sin notificaciones", "notification_empty_history": "Sin notificaciones",
@@ -638,9 +633,6 @@
"transcription_block_review": "Marcar como revisado", "transcription_block_review": "Marcar como revisado",
"transcription_block_unreview": "Desmarcar como revisado", "transcription_block_unreview": "Desmarcar como revisado",
"transcription_reviewed_count": "{reviewed} de {total} revisados", "transcription_reviewed_count": "{reviewed} de {total} revisados",
"transcription_mark_all_reviewed": "Marcar todo como revisado",
"transcription_mark_all_reviewed_disabled": "Todos los bloques ya están marcados como revisados",
"transcription_mark_all_reviewed_error": "Error al marcar como revisado. Intente de nuevo.",
"training_ocr_heading": "Entrenar reconocimiento Kurrent", "training_ocr_heading": "Entrenar reconocimiento Kurrent",
"training_ocr_description": "Inicia un nuevo entrenamiento con los bloques OCR revisados para mejorar la precisión de reconocimiento del script Kurrent.", "training_ocr_description": "Inicia un nuevo entrenamiento con los bloques OCR revisados para mejorar la precisión de reconocimiento del script Kurrent.",
"training_ocr_blocks_ready": "{blocks} bloques revisados listos / {docs} documentos", "training_ocr_blocks_ready": "{blocks} bloques revisados listos / {docs} documentos",

View File

@@ -1,20 +0,0 @@
// Shared mock for SvelteKit's $app/navigation virtual module.
// Activated by calling `vi.mock('$app/navigation')` (no factory) in a spec.
// Per ADR-012: eliminating per-spec factory bodies removes 36 birpc-race surface
// points; the unified mock keeps every nav export available as a vi.fn().
//
// IMPORTANT: consuming specs MUST call `vi.clearAllMocks()` (or per-mock
// `mockClear()`) in `afterEach` — otherwise call counts leak between tests.
import { vi } from 'vitest';
export const goto = vi.fn(async () => {});
export const invalidate = vi.fn(async () => {});
export const invalidateAll = vi.fn(async () => {});
export const beforeNavigate = vi.fn();
export const afterNavigate = vi.fn();
export const preloadCode = vi.fn(async () => {});
export const preloadData = vi.fn(async () => {});
export const pushState = vi.fn();
export const replaceState = vi.fn();
export const disableScrollHandling = vi.fn();
export const onNavigate = vi.fn(() => () => {});

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time'; import { relativeTime } from '$lib/shared/utils/time';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
@@ -7,13 +6,11 @@ import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
interface Props { interface Props {
unread: NotificationItem[]; unread: NotificationItem[];
optimisticMarkRead: (id: string) => void; onMarkRead: (n: NotificationItem) => void;
optimisticMarkAllRead: () => void; onMarkAllRead: () => void;
} }
const { unread, optimisticMarkRead, optimisticMarkAllRead }: Props = $props(); const { unread, onMarkRead, onMarkAllRead }: Props = $props();
let errorMessage: string | null = $state(null);
function verb(type: NotificationItem['type'], actor: string): string { function verb(type: NotificationItem['type'], actor: string): string {
return type === 'REPLY' return type === 'REPLY'
@@ -27,9 +24,6 @@ function href(n: NotificationItem): string {
</script> </script>
<section class="rounded-sm border border-line bg-surface p-5"> <section class="rounded-sm border border-line bg-surface p-5">
{#if errorMessage}
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
{/if}
{#if unread.length === 0} {#if unread.length === 0}
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center"> <div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
<svg <svg
@@ -72,28 +66,14 @@ function href(n: NotificationItem): string {
{m.chronik_for_you_count({ count: unread.length })} {m.chronik_for_you_count({ count: unread.length })}
</span> </span>
</div> </div>
<form <button
action="/aktivitaeten?/mark-all-read" type="button"
method="POST" data-testid="chronik-mark-all-read"
use:enhance={() => { onclick={onMarkAllRead}
errorMessage = null; class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
optimisticMarkAllRead();
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
<button {m.chronik_mark_all_read()}
type="submit" </button>
data-testid="chronik-mark-all-read"
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.chronik_mark_all_read()}
</button>
</form>
</div> </div>
<ul role="list" class="flex flex-col gap-2"> <ul role="list" class="flex flex-col gap-2">
@@ -109,7 +89,7 @@ function href(n: NotificationItem): string {
aria-hidden="true" aria-hidden="true"
class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent" class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent"
> >
{n.type === 'MENTION' ? '@' : ''} {n.type === 'MENTION' ? '@' : '\u21A9'}
</span> </span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="font-sans text-sm leading-snug text-ink"> <p class="font-sans text-sm leading-snug text-ink">
@@ -120,40 +100,25 @@ function href(n: NotificationItem): string {
</p> </p>
</div> </div>
</a> </a>
<form <button
action="/aktivitaeten?/dismiss-notification" type="button"
method="POST" data-testid="chronik-fuerdich-dismiss"
use:enhance={() => { aria-label={m.chronik_mark_read_aria()}
errorMessage = null; onclick={() => onMarkRead(n)}
optimisticMarkRead(n.id); class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
<input type="hidden" name="notificationId" value={n.id} /> <svg
<button xmlns="http://www.w3.org/2000/svg"
type="submit" class="h-4 w-4"
data-testid="chronik-fuerdich-dismiss" fill="none"
aria-label={m.chronik_mark_read_aria()} viewBox="0 0 24 24"
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none" stroke="currentColor"
stroke-width="2"
aria-hidden="true"
> >
<svg <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
xmlns="http://www.w3.org/2000/svg" </svg>
class="h-4 w-4" </button>
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -5,36 +5,7 @@ import { page, userEvent } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); afterEach(cleanup);
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
function notif(partial: Partial<NotificationItem>): NotificationItem { function notif(partial: Partial<NotificationItem>): NotificationItem {
return { return {
@@ -55,8 +26,8 @@ describe('ChronikFuerDichBox', () => {
it('renders inbox-zero state when there are no unread items', async () => { it('renders inbox-zero state when there are no unread items', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const zero = document.querySelector('[data-testid="chronik-inbox-zero"]'); const zero = document.querySelector('[data-testid="chronik-inbox-zero"]');
expect(zero).not.toBeNull(); expect(zero).not.toBeNull();
@@ -66,8 +37,8 @@ describe('ChronikFuerDichBox', () => {
it('links to the archived mentions in the inbox-zero state', async () => { it('links to the archived mentions in the inbox-zero state', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]'); const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
expect(link).not.toBeNull(); expect(link).not.toBeNull();
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
it('renders the count badge with correct total when unread exists', async () => { it('renders the count badge with correct total when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' }), notif({ id: 'b' })], unread: [notif({ id: 'a' }), notif({ id: 'b' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('2 neu')).toBeInTheDocument(); await expect.element(page.getByText('2 neu')).toBeInTheDocument();
}); });
@@ -85,8 +56,8 @@ describe('ChronikFuerDichBox', () => {
it('count badge has aria-live=polite when unread exists', async () => { it('count badge has aria-live=polite when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
// Wait for render // Wait for render
await expect.element(page.getByText('1 neu')).toBeInTheDocument(); await expect.element(page.getByText('1 neu')).toBeInTheDocument();
@@ -98,8 +69,8 @@ describe('ChronikFuerDichBox', () => {
it('does not render the "Alle gelesen" button when there are no unread items', async () => { it('does not render the "Alle gelesen" button when there are no unread items', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [], unread: [],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument(); await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument();
const all = document.querySelector('[data-testid="chronik-mark-all-read"]'); const all = document.querySelector('[data-testid="chronik-mark-all-read"]');
@@ -109,38 +80,38 @@ describe('ChronikFuerDichBox', () => {
it('renders the "Alle gelesen" button when unread exists', async () => { it('renders the "Alle gelesen" button when unread exists', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument(); await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument();
}); });
it('calls optimisticMarkAllRead when the "Alle gelesen" button is submitted', async () => { it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => {
const optimisticMarkAllRead = vi.fn(); const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'a' })], unread: [notif({ id: 'a' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead onMarkAllRead
}); });
await userEvent.click(page.getByText('Alle gelesen')); await userEvent.click(page.getByText('Alle gelesen'));
expect(optimisticMarkAllRead).toHaveBeenCalledTimes(1); expect(onMarkAllRead).toHaveBeenCalledTimes(1);
}); });
it('calls optimisticMarkRead with the notification id when its dismiss button is submitted', async () => { it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => {
const optimisticMarkRead = vi.fn(); const onMarkRead = vi.fn();
const n = notif({ id: 'xyz' }); const n = notif({ id: 'xyz' });
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [n], unread: [n],
optimisticMarkRead, onMarkRead,
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const dismiss = document.querySelector( const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]' '[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null; ) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull(); expect(dismiss).not.toBeNull();
dismiss?.click(); dismiss?.click();
expect(optimisticMarkRead).toHaveBeenCalledTimes(1); expect(onMarkRead).toHaveBeenCalledTimes(1);
expect(optimisticMarkRead.mock.calls[0][0]).toBe('xyz'); expect(onMarkRead.mock.calls[0][0]).toEqual(n);
}); });
it('mention row href includes both commentId and annotationId when annotationId is present', async () => { it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
@@ -153,8 +124,8 @@ describe('ChronikFuerDichBox', () => {
annotationId: 'annot-9' annotationId: 'annot-9'
}) })
], ],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const link = document.querySelector( const link = document.querySelector(
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]' 'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
@@ -165,8 +136,8 @@ describe('ChronikFuerDichBox', () => {
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => { it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
unread: [notif({ id: 'x' })], unread: [notif({ id: 'x' })],
optimisticMarkRead: vi.fn(), onMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn() onMarkAllRead: vi.fn()
}); });
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]'); const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
expect(dismiss).not.toBeNull(); expect(dismiss).not.toBeNull();
@@ -174,22 +145,4 @@ describe('ChronikFuerDichBox', () => {
// Prevents the senior-audience tap-drag bug flagged by Leonie. // Prevents the senior-audience tap-drag bug flagged by Leonie.
expect(dismiss?.closest('a')).toBeNull(); expect(dismiss?.closest('a')).toBeNull();
}); });
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
unread: [notif({ id: 'err-1' })],
optimisticMarkRead: vi.fn(),
optimisticMarkAllRead: vi.fn()
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLButtonElement | null;
expect(dismiss).not.toBeNull();
dismiss?.click();
// Allow microtask queue to flush
await new Promise((r) => setTimeout(r, 0));
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
}); });

View File

@@ -4,36 +4,7 @@ import { page } from 'vitest/browser';
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
import type { NotificationItem } from '$lib/notification/notifications'; import type { NotificationItem } from '$lib/notification/notifications';
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string })); afterEach(cleanup);
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await (
cb as (o: { result: typeof mockFormResult; update: () => Promise<void> }) => Promise<void>
)({ result: mockFormResult, update: async () => {} });
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => {
cleanup();
mockFormResult.type = 'success';
});
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n-1', id: 'n-1',
@@ -51,7 +22,7 @@ const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem =>
describe('ChronikFuerDichBox', () => { describe('ChronikFuerDichBox', () => {
it('renders the inbox-zero state when there are no unread', async () => { it('renders the inbox-zero state when there are no unread', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { unread: [], optimisticMarkRead: () => {}, optimisticMarkAllRead: () => {} } props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
}); });
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible(); await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
@@ -63,8 +34,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })], unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -76,8 +47,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })], unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -91,8 +62,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ actorName: 'Bertha' })], unread: [mention({ actorName: 'Bertha' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -105,8 +76,8 @@ describe('ChronikFuerDichBox', () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ type: 'REPLY', actorName: 'Carl' })], unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
@@ -115,11 +86,11 @@ describe('ChronikFuerDichBox', () => {
.toBeVisible(); .toBeVisible();
}); });
it('calls optimisticMarkRead with the notification id when its dismiss button is clicked', async () => { it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
const optimisticMarkRead = vi.fn(); const onMarkRead = vi.fn();
const item = mention({ id: 'n-7' }); const item = mention({ id: 'n-7' });
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { unread: [item], optimisticMarkRead, optimisticMarkAllRead: () => {} } props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
}); });
const dismiss = document.querySelector( const dismiss = document.querySelector(
@@ -127,55 +98,35 @@ describe('ChronikFuerDichBox', () => {
) as HTMLElement; ) as HTMLElement;
dismiss.click(); dismiss.click();
expect(optimisticMarkRead).toHaveBeenCalledWith('n-7'); expect(onMarkRead).toHaveBeenCalledWith(item);
}); });
it('calls optimisticMarkAllRead when the mark-all-read button is clicked', async () => { it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const optimisticMarkAllRead = vi.fn(); const onMarkAllRead = vi.fn();
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention()], unread: [mention()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead onMarkAllRead
} }
}); });
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement; const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
btn.click(); btn.click();
expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); expect(onMarkAllRead).toHaveBeenCalledOnce();
}); });
it('builds a deep-link href to the comment for each notification', async () => { it('builds a deep-link href to the comment for each notification', async () => {
render(ChronikFuerDichBox, { render(ChronikFuerDichBox, {
props: { props: {
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })], unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {} onMarkAllRead: () => {}
} }
}); });
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement; const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
expect(link.getAttribute('href')).toContain('doc-x'); expect(link.getAttribute('href')).toContain('doc-x');
}); });
it('shows an accessible error banner when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
render(ChronikFuerDichBox, {
props: {
unread: [mention({ id: 'err-1' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {}
}
});
const dismiss = document.querySelector(
'[data-testid="chronik-fuerdich-dismiss"]'
) as HTMLElement;
dismiss.click();
// Allow microtask queue to flush
await new Promise((r) => setTimeout(r, 0));
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
});
}); });

View File

@@ -17,7 +17,6 @@ import PdfViewer from '$lib/document/viewer/PdfViewer.svelte';
import { bulkTitleFromFilename } from '$lib/document/filename'; import { bulkTitleFromFilename } from '$lib/document/filename';
import type { Tag } from '$lib/tag/TagInput.svelte'; import type { Tag } from '$lib/tag/TagInput.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { withCsrf } from '$lib/shared/cookies';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
@@ -184,10 +183,7 @@ async function saveUpload() {
// FormData with per-chunk progress. Session cookie is sent automatically // FormData with per-chunk progress. Session cookie is sent automatically
// by the browser for same-origin requests. // by the browser for same-origin requests.
try { try {
const res = await fetch( const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
'/api/documents/quick-upload',
withCsrf({ method: 'POST', body: formData })
);
const body = await res.json().catch(() => ({ errors: [] })); const body = await res.json().catch(() => ({ errors: [] }));
const errorFilenames = new Set<string>( const errorFilenames = new Set<string>(
(body.errors ?? []).map((err: { filename: string }) => err.filename) (body.errors ?? []).map((err: { filename: string }) => err.filename)

View File

@@ -4,7 +4,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte'; import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();

View File

@@ -5,7 +5,7 @@ import { goto } from '$app/navigation';
import BulkSelectionBar from './BulkSelectionBar.svelte'; import BulkSelectionBar from './BulkSelectionBar.svelte';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte'; import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();

View File

@@ -6,7 +6,7 @@ import DocumentRow from './DocumentRow.svelte';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte'; import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();

View File

@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
const { default: DocumentRow } = await import('./DocumentRow.svelte'); const { default: DocumentRow } = await import('./DocumentRow.svelte');

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import TranscriptionBlockHost from './TranscriptionBlock.test-fixture.svelte'; import TranscriptionBlockHost from './TranscriptionBlock.test-host.svelte';
import type { ConfirmService } from '$lib/shared/services/confirm.svelte.js'; import type { ConfirmService } from '$lib/shared/services/confirm.svelte.js';
afterEach(cleanup); afterEach(cleanup);

View File

@@ -6,7 +6,6 @@ import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyStat
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types'; import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte'; import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte'; import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
import { withCsrf } from '$lib/shared/cookies';
type Props = { type Props = {
documentId: string; documentId: string;
@@ -50,7 +49,6 @@ let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]); let localLabels: string[] = $derived.by(() => [...trainingLabels]);
let listEl: HTMLElement | null = $state(null); let listEl: HTMLElement | null = $state(null);
let markingAllReviewed = $state(false); let markingAllReviewed = $state(false);
let markAllError = $state<string | null>(null);
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder)); const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
const hasBlocks = $derived(blocks.length > 0); const hasBlocks = $derived(blocks.length > 0);
@@ -69,11 +67,8 @@ $effect(() => {
async function handleMarkAllReviewed() { async function handleMarkAllReviewed() {
if (!onMarkAllReviewed) return; if (!onMarkAllReviewed) return;
markingAllReviewed = true; markingAllReviewed = true;
markAllError = null;
try { try {
await onMarkAllReviewed(); await onMarkAllReviewed();
} catch {
markAllError = m.transcription_mark_all_reviewed_error();
} finally { } finally {
markingAllReviewed = false; markingAllReviewed = false;
} }
@@ -114,14 +109,11 @@ function handleDelete(blockId: string) {
async function reorder(newOrder: string[]) { async function reorder(newOrder: string[]) {
try { try {
const res = await fetch( const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
`/api/documents/${documentId}/transcription-blocks/reorder`, method: 'PUT',
withCsrf({ headers: { 'Content-Type': 'application/json' },
method: 'PUT', body: JSON.stringify({ blockIds: newOrder })
headers: { 'Content-Type': 'application/json' }, });
body: JSON.stringify({ blockIds: newOrder })
})
);
if (!res.ok) return; if (!res.ok) return;
const updated = await res.json(); const updated = await res.json();
for (const b of updated) { for (const b of updated) {
@@ -177,7 +169,7 @@ async function handleLabelToggle(label: string) {
<button <button
onclick={handleMarkAllReviewed} onclick={handleMarkAllReviewed}
disabled={allReviewed || markingAllReviewed} disabled={allReviewed || markingAllReviewed}
title={allReviewed ? m.transcription_mark_all_reviewed_disabled() : undefined} title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40" class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
> >
{#if markingAllReviewed} {#if markingAllReviewed}
@@ -215,7 +207,7 @@ async function handleLabelToggle(label: string) {
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
{/if} {/if}
{m.transcription_mark_all_reviewed()} Alle als fertig markieren
</button> </button>
{/if} {/if}
</div> </div>
@@ -225,31 +217,6 @@ async function handleLabelToggle(label: string) {
style="width: {reviewProgress}%" style="width: {reviewProgress}%"
></div> ></div>
</div> </div>
{#if markAllError}
<div
role="alert"
class="mt-1.5 flex items-center gap-2 rounded-sm border border-red-200 bg-red-50 px-3 py-2 font-sans text-sm text-red-700"
>
<span class="flex-1">{markAllError}</span>
<button
onclick={() => (markAllError = null)}
aria-label={m.comp_dismiss()}
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-red-600 hover:text-red-700 focus-visible:ring-2 focus-visible:ring-red-500"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
</div> </div>
<div class="p-4"> <div class="p-4">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->

View File

@@ -3,7 +3,6 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import TranscriptionEditView from './TranscriptionEditView.svelte'; import TranscriptionEditView from './TranscriptionEditView.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import { m } from '$lib/paraglide/messages.js';
afterEach(cleanup); afterEach(cleanup);
@@ -313,14 +312,14 @@ describe('TranscriptionEditView — mark all reviewed', () => {
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined) onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
}); });
await expect await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeInTheDocument(); .toBeInTheDocument();
}); });
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => { it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] }); renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
await expect await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
}); });
@@ -330,7 +329,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined) onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
}); });
await expect await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled(); .toBeDisabled();
}); });
@@ -344,7 +343,7 @@ describe('TranscriptionEditView — mark all reviewed', () => {
// userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick // userEvent.click() via Playwright CDP doesn't reliably trigger Svelte 5 onclick
// handlers when a TipTap editor is mounted in the same component tree. // handlers when a TipTap editor is mounted in the same component tree.
const btn = (await page const btn = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() }) .getByRole('button', { name: /Alle als fertig markieren/ })
.element()) as HTMLButtonElement; .element()) as HTMLButtonElement;
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
@@ -362,83 +361,12 @@ describe('TranscriptionEditView — mark all reviewed', () => {
// Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick // Same CDP click workaround: dispatch from browser JS to reliably fire Svelte 5 onclick
const btnEl = (await page const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() }) .getByRole('button', { name: /Alle als fertig markieren/ })
.element()) as HTMLButtonElement; .element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() })) .element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled(); .toBeDisabled();
resolveMarkAll(); resolveMarkAll();
}); });
it('shows error message when onMarkAllReviewed callback rejects', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect
.element(page.getByRole('alert'))
.toHaveTextContent(m.transcription_mark_all_reviewed_error());
});
it('clears error when dismiss button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const dismissEl = (await page
.getByRole('button', { name: m.comp_dismiss() })
.element()) as HTMLButtonElement;
dismissEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('clears error on next successful markAllReviewed call', async () => {
const onMarkAllReviewed = vi
.fn()
.mockRejectedValueOnce(new Error('INTERNAL_ERROR'))
.mockResolvedValue(undefined);
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
// Wait for the button to be re-enabled before the second click — ensures the first
// async rejection has fully settled and Svelte has flushed state changes
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.not.toBeDisabled();
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('re-enables button after markAllReviewed failure', async () => {
const onMarkAllReviewed = vi.fn().mockRejectedValue(new Error('INTERNAL_ERROR'));
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2], onMarkAllReviewed });
const btnEl = (await page
.getByRole('button', { name: m.transcription_mark_all_reviewed() })
.element()) as HTMLButtonElement;
btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: m.transcription_mark_all_reviewed() }))
.not.toBeDisabled();
});
}); });

View File

@@ -1,6 +1,5 @@
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import type { PersonMention } from '$lib/shared/types'; import type { PersonMention } from '$lib/shared/types';
import { withCsrf } from '$lib/shared/cookies';
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
@@ -117,15 +116,12 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
for (const [blockId, text] of pendingTexts) { for (const [blockId, text] of pendingTexts) {
const mentions = pendingMentions.get(blockId) ?? []; const mentions = pendingMentions.get(blockId) ?? [];
clearDebounce(blockId); clearDebounce(blockId);
void fetch( void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
`/api/documents/${documentId}/transcription-blocks/${blockId}`, method: 'PUT',
withCsrf({ headers: { 'Content-Type': 'application/json' },
method: 'PUT', body: JSON.stringify({ text, mentionedPersons: mentions }),
headers: { 'Content-Type': 'application/json' }, keepalive: true
body: JSON.stringify({ text, mentionedPersons: mentions }), });
keepalive: true
})
);
pendingTexts.delete(blockId); pendingTexts.delete(blockId);
pendingMentions.delete(blockId); pendingMentions.delete(blockId);
} }

View File

@@ -259,15 +259,12 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true); expect(ctrl.blocks.every((b) => b.reviewed)).toBe(true);
}); });
it('throws and leaves blocks unchanged when PUT returns non-OK', async () => { it('is a no-op when PUT returns non-OK', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString(); const u = url.toString();
const method = init?.method ?? 'GET'; const method = init?.method ?? 'GET';
if (u.includes('/review-all') && method === 'PUT') { if (u.includes('/review-all') && method === 'PUT') {
return new Response(JSON.stringify({ code: 'INTERNAL_ERROR' }), { return new Response('', { status: 500 });
status: 500,
headers: { 'Content-Type': 'application/json' }
});
} }
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), { return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
status: 200, status: 200,
@@ -277,26 +274,7 @@ describe('createTranscriptionBlocks.markAllReviewed', () => {
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl }); const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load(); await ctrl.load();
await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR'); await ctrl.markAllReviewed();
expect(ctrl.blocks[0].reviewed).toBe(false);
});
it('throws INTERNAL_ERROR when PUT returns non-JSON body (e.g. nginx 502)', async () => {
const fetchImpl = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
const u = url.toString();
const method = init?.method ?? 'GET';
if (u.includes('/review-all') && method === 'PUT') {
return new Response('Bad Gateway', { status: 502 });
}
return new Response(JSON.stringify([baseBlock({ id: 'b-1', reviewed: false })]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
});
const ctrl = createTranscriptionBlocks({ documentId: () => 'doc-1', fetchImpl });
await ctrl.load();
await expect(ctrl.markAllReviewed()).rejects.toThrow('INTERNAL_ERROR');
expect(ctrl.blocks[0].reviewed).toBe(false); expect(ctrl.blocks[0].reviewed).toBe(false);
}); });
}); });

View File

@@ -2,7 +2,6 @@
lastEditedAt's $derived are scope-local to one computation; they're never lastEditedAt's $derived are scope-local to one computation; they're never
stored on $state. */ stored on $state. */
import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types'; import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types';
import { makeCsrfFetch } from '$lib/shared/cookies';
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry'; import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
import { BlockConflictResolvedError } from './blockConflictMerge'; import { BlockConflictResolvedError } from './blockConflictMerge';
@@ -42,7 +41,7 @@ export function createTranscriptionBlocks(
options: TranscriptionBlocksOptions options: TranscriptionBlocksOptions
): TranscriptionBlocksController { ): TranscriptionBlocksController {
const { documentId } = options; const { documentId } = options;
const fetchImpl = makeCsrfFetch(options.fetchImpl ?? fetch); const fetchImpl = options.fetchImpl ?? fetch;
let blocks = $state<TranscriptionBlockData[]>([]); let blocks = $state<TranscriptionBlockData[]>([]);
let annotationReloadKey = $state(0); let annotationReloadKey = $state(0);
@@ -120,11 +119,7 @@ export function createTranscriptionBlocks(
const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, { const res = await fetchImpl(`/api/documents/${documentId()}/transcription-blocks/review-all`, {
method: 'PUT' method: 'PUT'
}); });
if (!res.ok) { if (!res.ok) return;
const body = await res.json().catch(() => ({}));
// Never render body.message — route through getErrorMessage() to prevent leaking backend internals
throw new Error((body as { code?: string })?.code ?? 'INTERNAL_ERROR');
}
const updated = (await res.json()) as { id: string; reviewed: boolean }[]; const updated = (await res.json()) as { id: string; reviewed: boolean }[];
for (const b of updated) { for (const b of updated) {
const existing = blocks.find((x) => x.id === b.id); const existing = blocks.find((x) => x.id === b.id);

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside'; import { clickOutside } from '$lib/shared/actions/clickOutside';
import { notificationStore } from '$lib/notification/notifications.svelte'; import { notificationStore } from '$lib/notification/notifications.svelte';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import NotificationDropdown from './NotificationDropdown.svelte'; import NotificationDropdown from './NotificationDropdown.svelte';
let open = $state(false); let open = $state(false);
@@ -28,6 +30,17 @@ function closeDropdown() {
bellButtonEl?.focus(); bellButtonEl?.focus();
} }
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
await stream.markRead(notification);
const url = buildCommentHref(
notification.documentId,
notification.referenceId,
notification.annotationId
);
closeDropdown();
goto(url);
}
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) { if (event.key === 'Escape' && open) {
event.stopPropagation(); event.stopPropagation();
@@ -100,8 +113,8 @@ onDestroy(() => {
{#if open} {#if open}
<NotificationDropdown <NotificationDropdown
notifications={stream.notifications} notifications={stream.notifications}
optimisticMarkRead={stream.optimisticMarkRead} onMarkRead={handleMarkRead}
optimisticMarkAllRead={stream.optimisticMarkAllRead} onMarkAllRead={stream.markAllRead}
onClose={closeDropdown} onClose={closeDropdown}
/> />
{/if} {/if}

View File

@@ -3,18 +3,10 @@ import { cleanup, render } from 'vitest-browser-svelte';
import type { NotificationItem } from '$lib/notification/notifications'; import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte'; import NotificationBell from './NotificationBell.svelte';
vi.mock('$app/navigation'); const gotoMock = vi.hoisted(() => vi.fn());
vi.mock('$app/forms', () => ({ vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
const handler = (e: Event) => {
e.preventDefault();
submit?.({ formData: new FormData(node) } as never);
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] })); const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
vi.mock('$lib/notification/notifications.svelte', () => ({ vi.mock('$lib/notification/notifications.svelte', () => ({
@@ -25,17 +17,18 @@ vi.mock('$lib/notification/notifications.svelte', () => ({
get unreadCount() { get unreadCount() {
return mockNotificationList.value.length; return mockNotificationList.value.length;
}, },
optimisticMarkRead: vi.fn(), markRead: mockMarkRead,
optimisticMarkAllRead: vi.fn(),
fetchNotifications: vi.fn().mockResolvedValue(undefined), fetchNotifications: vi.fn().mockResolvedValue(undefined),
init: vi.fn(), init: vi.fn(),
destroy: vi.fn() destroy: vi.fn(),
markAllRead: vi.fn()
} }
})); }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); gotoMock.mockClear();
mockMarkRead.mockClear();
mockNotificationList.value = []; mockNotificationList.value = [];
}); });
@@ -52,6 +45,16 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
...overrides ...overrides
}); });
async function openDropdownAndClickFirstNotification() {
const bellButton = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
bellButton.click();
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
const notifButton = document.querySelector<HTMLButtonElement>('[role="list"] button')!;
notifButton.click();
}
describe('NotificationBell — cursor and tooltip', () => { describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => { it('bell button has cursor-pointer class', async () => {
render(NotificationBell); render(NotificationBell);
@@ -79,3 +82,29 @@ describe('NotificationBell — cursor and tooltip', () => {
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
}); });
}); });
describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith(
'/documents/doc-1?commentId=ref-1&annotationId=annot-1'
);
});
});
it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => {
mockNotificationList.value = [makeNotification({ annotationId: null })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1');
});
});
});

View File

@@ -1,21 +1,17 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/shared/utils/time'; import { relativeTime } from '$lib/shared/utils/time';
import { buildCommentHref } from '$lib/shared/discussion/commentDeepLink';
import type { NotificationItem } from '$lib/notification/notifications.svelte'; import type { NotificationItem } from '$lib/notification/notifications.svelte';
type Props = { type Props = {
notifications: NotificationItem[]; notifications: NotificationItem[];
optimisticMarkRead: (id: string) => void; onMarkRead: (notification: NotificationItem) => void;
optimisticMarkAllRead: () => void; onMarkAllRead: () => void;
onClose: () => void; onClose: () => void;
}; };
let { notifications, optimisticMarkRead, optimisticMarkAllRead, onClose }: Props = $props(); let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
let errorMessage = $state<string | null>(null);
function handleViewAll() { function handleViewAll() {
onClose(); // close first — avoids stale dropdown during navigation transition onClose(); // close first — avoids stale dropdown during navigation transition
@@ -35,35 +31,16 @@ function handleViewAll() {
{m.notification_bell_label()} {m.notification_bell_label()}
</span> </span>
{#if notifications.length > 0} {#if notifications.length > 0}
<form <button
action="/aktivitaeten?/mark-all-read" type="button"
method="POST" onclick={onMarkAllRead}
use:enhance={() => { class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
errorMessage = null;
optimisticMarkAllRead();
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
}
};
}}
> >
<button {m.notification_mark_all_read()}
type="submit" </button>
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.notification_mark_all_read()}
</button>
</form>
{/if} {/if}
</div> </div>
<!-- Error banner (shown when a dismiss or mark-all action fails) -->
{#if errorMessage}
<p role="alert" class="px-4 py-2 text-sm text-red-600">{errorMessage}</p>
{/if}
<!-- Notification list --> <!-- Notification list -->
{#if notifications.length === 0} {#if notifications.length === 0}
<!-- Empty state --> <!-- Empty state -->
@@ -89,93 +66,67 @@ function handleViewAll() {
<ul role="list" class="max-h-[24rem] overflow-y-auto"> <ul role="list" class="max-h-[24rem] overflow-y-auto">
{#each notifications as notification (notification.id)} {#each notifications as notification (notification.id)}
<li> <li>
<form <button
action="/aktivitaeten?/dismiss-notification" type="button"
method="POST" onclick={() => onMarkRead(notification)}
class="contents" class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
use:enhance={() => { {!notification.read ? 'bg-accent-bg/20' : ''}"
errorMessage = null;
optimisticMarkRead(notification.id);
return async ({ result, update }) => {
if (result.type === 'failure' || result.type === 'error') {
errorMessage = (result as { data?: { error?: string } }).data?.error ?? m.notification_error_generic();
await update({ reset: false, invalidateAll: false });
} else {
// Navigate away — no need to update the store since we're leaving the page
onClose();
goto(
buildCommentHref(
notification.documentId,
notification.referenceId,
notification.annotationId
)
);
}
};
}}
> >
<input type="hidden" name="notificationId" value={notification.id} /> <!-- Type icon -->
<button <span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
type="submit" {#if notification.type === 'REPLY'}
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3.5 text-left last:border-b-0 hover:bg-canvas <!-- Reply icon -->
{!notification.read ? 'bg-accent-bg/20' : ''}" <svg
> xmlns="http://www.w3.org/2000/svg"
<!-- Type icon --> class="h-4 w-4"
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true"> fill="none"
{#if notification.type === 'REPLY'} viewBox="0 0 24 24"
<!-- Reply icon --> stroke="currentColor"
<svg stroke-width="2"
xmlns="http://www.w3.org/2000/svg" >
class="h-4 w-4" <path
fill="none" stroke-linecap="round"
viewBox="0 0 24 24" stroke-linejoin="round"
stroke="currentColor" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
stroke-width="2" />
> </svg>
<path {:else}
stroke-linecap="round" <!-- Mention icon -->
stroke-linejoin="round" <svg
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" xmlns="http://www.w3.org/2000/svg"
/> class="h-4 w-4"
</svg> fill="none"
{:else} viewBox="0 0 24 24"
<!-- Mention icon --> stroke="currentColor"
<svg stroke-width="2"
xmlns="http://www.w3.org/2000/svg" >
class="h-4 w-4" <path
fill="none" stroke-linecap="round"
viewBox="0 0 24 24" stroke-linejoin="round"
stroke="currentColor" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
stroke-width="2" />
> </svg>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
{/if}
</span>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
{/if} {/if}
</button> </span>
</form>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
{/if}
</button>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@@ -4,40 +4,11 @@ import { page } from 'vitest/browser';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import NotificationDropdown from './NotificationDropdown.svelte'; import NotificationDropdown from './NotificationDropdown.svelte';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
// Configurable result for the enhance mock — tests that need failure set
// mockFormResult.type = 'failure' before clicking.
const mockFormResult = vi.hoisted(() => ({ type: 'success' as string }));
// Invoke the SubmitFunction and always call the returned result callback with
// mockFormResult so tests can exercise both success and failure branches.
vi.mock('$app/forms', () => ({
enhance(
node: HTMLFormElement,
submit?: (opts: {
formData: FormData;
}) => (opts: {
result: { type: string; data?: Record<string, unknown> };
update: () => Promise<void>;
}) => Promise<void>
) {
const handler = async (e: Event) => {
e.preventDefault();
const cb = submit?.({ formData: new FormData(node) } as never);
if (typeof cb === 'function') {
await cb({ result: mockFormResult, update: async () => {} } as never);
}
};
node.addEventListener('submit', handler);
return { destroy: () => node.removeEventListener('submit', handler) };
}
}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
mockFormResult.type = 'success'; // reset to default after each test
}); });
const makeNotification = (overrides: Record<string, unknown> = {}) => ({ const makeNotification = (overrides: Record<string, unknown> = {}) => ({
@@ -58,8 +29,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -71,8 +42,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -84,8 +55,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -99,8 +70,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification()], notifications: [makeNotification()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -112,8 +83,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })], notifications: [makeNotification({ type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -127,8 +98,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })], notifications: [makeNotification({ type: 'MENTION', actorName: 'Clara' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -145,8 +116,8 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', read: false }), makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: true }) makeNotification({ id: 'n2', read: true })
], ],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -155,100 +126,37 @@ describe('NotificationDropdown', () => {
expect(unreadDots.length).toBe(1); expect(unreadDots.length).toBe(1);
}); });
it('each notification row is wrapped in a form posting to the dismiss action', async () => { it('calls onMarkRead with the notification when an item is clicked', async () => {
render(NotificationDropdown, { const onMarkRead = vi.fn();
props: {
notifications: [makeNotification({ id: 'n42' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const form = document.querySelector('form[action="/aktivitaeten?/dismiss-notification"]');
expect(form).not.toBeNull();
expect(form?.getAttribute('method')).toBe('POST');
});
it('the dismiss form has a hidden notificationId input with the notification id', async () => {
render(NotificationDropdown, {
props: {
notifications: [makeNotification({ id: 'n42' })],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
const input = document.querySelector<HTMLInputElement>(
'form[action="/aktivitaeten?/dismiss-notification"] input[name="notificationId"]'
);
expect(input?.value).toBe('n42');
});
it('calls optimisticMarkRead with the notification id when a row is submitted', async () => {
const optimisticMarkRead = vi.fn();
const n = makeNotification({ id: 'n42', actorName: 'Anna' }); const n = makeNotification({ id: 'n42', actorName: 'Anna' });
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [n], notifications: [n],
optimisticMarkRead, onMarkRead,
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click(); await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(optimisticMarkRead).toHaveBeenCalledWith('n42'); expect(onMarkRead).toHaveBeenCalledWith(n);
}); });
it('the mark-all-read control is a form posting to the mark-all-read action', async () => { it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
const onMarkAllRead = vi.fn();
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification()], notifications: [makeNotification()],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead,
onClose: () => {}
}
});
const form = document.querySelector('form[action="/aktivitaeten?/mark-all-read"]');
expect(form).not.toBeNull();
expect(form?.getAttribute('method')).toBe('POST');
});
it('calls optimisticMarkAllRead when the mark-all-read button is submitted', async () => {
const optimisticMarkAllRead = vi.fn();
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead,
onClose: () => {} onClose: () => {}
} }
}); });
await page.getByRole('button', { name: /alle gelesen/i }).click(); await page.getByRole('button', { name: /alle gelesen/i }).click();
expect(optimisticMarkAllRead).toHaveBeenCalledOnce(); expect(onMarkAllRead).toHaveBeenCalledOnce();
});
it('shows a role=alert error banner when mark-all-read returns a failure', async () => {
mockFormResult.type = 'failure';
render(NotificationDropdown, {
props: {
notifications: [makeNotification()],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /alle gelesen/i }).click();
const alert = document.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
}); });
it('calls onClose when the view-all button is clicked', async () => { it('calls onClose when the view-all button is clicked', async () => {
@@ -256,8 +164,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose onClose
} }
}); });
@@ -271,8 +179,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -285,15 +193,12 @@ describe('NotificationDropdown', () => {
it('calls onClose before navigating to /aktivitaeten', async () => { it('calls onClose before navigating to /aktivitaeten', async () => {
const callOrder: string[] = []; const callOrder: string[] = [];
const onClose = vi.fn(() => callOrder.push('close')); const onClose = vi.fn(() => callOrder.push('close'));
vi.mocked(goto).mockImplementation(() => { vi.mocked(goto).mockImplementation(() => callOrder.push('goto'));
callOrder.push('goto');
return Promise.resolve();
});
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [], notifications: [],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose onClose
} }
}); });
@@ -307,8 +212,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })], notifications: [makeNotification({ id: 'm1', type: 'MENTION', actorName: 'Anna' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -320,8 +225,8 @@ describe('NotificationDropdown', () => {
render(NotificationDropdown, { render(NotificationDropdown, {
props: { props: {
notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })], notifications: [makeNotification({ id: 'r1', type: 'REPLY', actorName: 'Bert' })],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
@@ -337,78 +242,14 @@ describe('NotificationDropdown', () => {
makeNotification({ id: 'n1', actorName: 'First' }), makeNotification({ id: 'n1', actorName: 'First' }),
makeNotification({ id: 'n2', actorName: 'Second' }) makeNotification({ id: 'n2', actorName: 'Second' })
], ],
optimisticMarkRead: () => {}, onMarkRead: () => {},
optimisticMarkAllRead: () => {}, onMarkAllRead: () => {},
onClose: () => {} onClose: () => {}
} }
}); });
const forms = document.querySelectorAll('form[action="/aktivitaeten?/dismiss-notification"]'); const items = document.querySelectorAll('button[type="button"]');
expect(forms.length).toBe(2); // At least 2 items + mark-all button
}); expect(items.length).toBeGreaterThanOrEqual(2);
it('calls onClose and goto with the deep-link URL after a successful dismiss', async () => {
const onClose = vi.fn();
const n = makeNotification({
id: 'n42',
documentId: 'd1',
referenceId: 'c1',
annotationId: null,
actorName: 'Anna'
});
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /Anna hat auf deinen/i }).click();
expect(onClose).toHaveBeenCalledOnce();
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1');
});
it('does NOT call onClose or goto when the dismiss action returns a failure', async () => {
mockFormResult.type = 'failure';
const onClose = vi.fn();
const n = makeNotification({ id: 'n99', actorName: 'Bob' });
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose
}
});
await page.getByRole('button', { name: /Bob hat auf deinen/i }).click();
expect(onClose).not.toHaveBeenCalled();
expect(goto).not.toHaveBeenCalled();
});
it('calls goto with annotationId appended when the notification has an annotationId', async () => {
const n = makeNotification({
id: 'n55',
documentId: 'd1',
referenceId: 'c1',
annotationId: 'a1',
actorName: 'Eva'
});
render(NotificationDropdown, {
props: {
notifications: [n],
optimisticMarkRead: () => {},
optimisticMarkAllRead: () => {},
onClose: () => {}
}
});
await page.getByRole('button', { name: /Eva hat auf deinen/i }).click();
expect(goto).toHaveBeenCalledWith('/documents/d1?commentId=c1&annotationId=a1');
}); });
}); });

View File

@@ -108,46 +108,12 @@ describe('notificationStore (singleton)', () => {
expect(notificationStore.unreadCount).toBe(1); expect(notificationStore.unreadCount).toBe(1);
}); });
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => { it('markAllRead resets unreadCount', async () => {
notificationStore.init(); mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
const notification = makeNotification({ id: 'sse-1', read: false }); await notificationStore.markAllRead();
lastEventSource!.simulate('notification', JSON.stringify(notification));
mockFetch.mockReset(); // clear the fetchUnreadCount call from init
notificationStore.optimisticMarkRead('sse-1'); expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
expect(notificationStore.notifications[0].read).toBe(true);
expect(notificationStore.unreadCount).toBe(0); expect(notificationStore.unreadCount).toBe(0);
expect(mockFetch).not.toHaveBeenCalled();
});
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
notificationStore.init();
const notification = makeNotification({ id: 'sse-1', read: true });
lastEventSource!.simulate('notification', JSON.stringify(notification));
notificationStore.optimisticMarkRead('sse-1');
expect(notificationStore.unreadCount).toBe(0);
});
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
notificationStore.init();
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n1', read: false }))
);
lastEventSource!.simulate(
'notification',
JSON.stringify(makeNotification({ id: 'n2', read: false }))
);
mockFetch.mockReset();
notificationStore.optimisticMarkAllRead();
expect(notificationStore.unreadCount).toBe(0);
expect(notificationStore.notifications.every((n) => n.read)).toBe(true);
expect(mockFetch).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -35,19 +35,28 @@ async function fetchUnreadCount(): Promise<void> {
} }
} }
function optimisticMarkRead(id: string): void { async function markRead(notification: NotificationItem): Promise<void> {
const notification = notifications.find((n) => n.id === id); if (!notification.read) {
if (notification && !notification.read) { try {
notification.read = true; await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
unreadCount = Math.max(0, unreadCount - 1); notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
} }
} }
function optimisticMarkAllRead(): void { async function markAllRead(): Promise<void> {
for (const n of notifications) { try {
n.read = true; await fetch('/api/notifications/read-all', { method: 'POST' });
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
} }
unreadCount = 0;
} }
function init(): void { function init(): void {
@@ -114,8 +123,8 @@ export const notificationStore = {
}, },
fetchNotifications, fetchNotifications,
fetchUnreadCount, fetchUnreadCount,
optimisticMarkRead, markRead,
optimisticMarkAllRead, markAllRead,
init, init,
destroy destroy
}; };

View File

@@ -2,7 +2,6 @@
import TrainingHistory from './TrainingHistory.svelte'; import TrainingHistory from './TrainingHistory.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import type { TrainingRun } from '$lib/ocr/training.js'; import type { TrainingRun } from '$lib/ocr/training.js';
import { withCsrf } from '$lib/shared/cookies';
interface TrainingInfo { interface TrainingInfo {
availableBlocks?: number; availableBlocks?: number;
@@ -34,7 +33,7 @@ async function startTraining() {
successMessage = null; successMessage = null;
errorMessage = null; errorMessage = null;
try { try {
const res = await fetch('/api/ocr/train', withCsrf({ method: 'POST' })); const res = await fetch('/api/ocr/train', { method: 'POST' });
if (res.ok) { if (res.ok) {
successMessage = m.training_success(); successMessage = m.training_success();
setTimeout(() => { setTimeout(() => {

View File

@@ -2,7 +2,6 @@
import TrainingHistory from './TrainingHistory.svelte'; import TrainingHistory from './TrainingHistory.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import type { TrainingRun } from '$lib/ocr/training.js'; import type { TrainingRun } from '$lib/ocr/training.js';
import { withCsrf } from '$lib/shared/cookies';
interface TrainingInfo { interface TrainingInfo {
availableSegBlocks?: number; availableSegBlocks?: number;
@@ -28,7 +27,7 @@ async function startTraining() {
training = true; training = true;
successMessage = null; successMessage = null;
try { try {
const res = await fetch('/api/ocr/segtrain', withCsrf({ method: 'POST' })); const res = await fetch('/api/ocr/segtrain', { method: 'POST' });
if (res.ok) { if (res.ok) {
successMessage = m.training_success(); successMessage = m.training_success();
setTimeout(() => { setTimeout(() => {

View File

@@ -3,7 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import StammbaumSidePanel from './StammbaumSidePanel.svelte'; import StammbaumSidePanel from './StammbaumSidePanel.svelte';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn() }));
vi.mock('$app/forms', () => ({ enhance: () => () => {} })); vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null })); vi.mock('$lib/person/PersonTypeahead.svelte', () => ({ default: () => null }));

View File

@@ -3,7 +3,19 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import StammbaumSidePanel from './StammbaumSidePanel.svelte'; import StammbaumSidePanel from './StammbaumSidePanel.svelte';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
afterEach(cleanup); afterEach(cleanup);

View File

@@ -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();
});
});

View File

@@ -23,11 +23,3 @@ export function createApiClient(fetch: typeof globalThis.fetch) {
fetch fetch
}); });
} }
export interface ApiError {
code?: string;
}
export function extractErrorCode(error: unknown): string | undefined {
return (error as ApiError | undefined)?.code;
}

View File

@@ -1,46 +1,3 @@
/**
* Reads the XSRF-TOKEN cookie set by Spring Security's CookieCsrfTokenRepository.
* Returns null outside the browser or when the cookie is absent.
*/
export function getCsrfToken(): string | null {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Merges the X-XSRF-TOKEN header into a RequestInit so Spring Security's
* CSRF filter accepts the request. Safe to call server-side (no-op when the
* cookie is absent).
*/
export function withCsrf(init?: RequestInit): RequestInit {
const token = getCsrfToken();
if (!token) return init ?? {};
const headers = new Headers(init?.headers);
headers.set('X-XSRF-TOKEN', token);
return { ...init, headers };
}
/**
* Wraps a fetch implementation so that every state-mutating call (POST, PUT,
* PATCH, DELETE) automatically includes the X-XSRF-TOKEN header. GET/HEAD
* requests pass through unchanged.
*
* Used to CSRF-protect client-side hooks that accept an injectable fetchImpl.
* In unit tests the injected mock is wrapped but getCsrfToken() returns null
* (no browser cookie), so no header is added and existing test expectations
* are unaffected.
*/
export function makeCsrfFetch(inner: typeof fetch): typeof fetch {
return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const method = (init?.method ?? 'GET').toUpperCase();
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
return inner(input, withCsrf(init));
}
return inner(input, init);
};
}
/** /**
* Extracts the fa_session cookie value from a list of Set-Cookie response headers. * Extracts the fa_session cookie value from a list of Set-Cookie response headers.
* *

View File

@@ -2,18 +2,7 @@
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
// eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable // eslint-disable-next-line boundaries/dependencies -- mention dropdown needs person date formatting; extract to shared if it becomes reusable
import { formatLifeDateRange } from '$lib/person/personLifeDates'; import { formatLifeDateRange } from '$lib/person/personLifeDates';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
// Layered defence cap on the @mention search query length (CWE-400
// amplification). The <input maxlength> attribute below caps direct
// user edits, but the editor-mirror path (Tiptap contenteditable -> mirror
// $effect -> searchQuery) is not covered by `maxlength` since the
// contenteditable has no such enforcement. Clipping at the mirror keeps
// the cap honest from both paths. Tracked server-side separately.
// Nora #1 on PR #629. Hoisted to mentionConstants.ts so the host editor
// (PersonMentionEditor) can clip the inserted displayName to the same cap
// — see Felix #3 on PR #629.
import { MAX_QUERY_LENGTH } from './mentionConstants';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
@@ -28,46 +17,7 @@ type DropdownState = {
clientRect: (() => DOMRect | null) | null; clientRect: (() => DOMRect | null) | null;
}; };
let { let { model }: { model: DropdownState } = $props();
model,
editorQuery = '',
onSearch = () => {}
}: {
model: DropdownState;
/** Text typed after `@` in the host editor. Mirrors into the search input
* until the user takes manual ownership by typing into the input itself. */
editorQuery?: string;
onSearch?: (query: string) => void;
} = $props();
let searchQuery = $state(untrack(() => editorQuery.slice(0, MAX_QUERY_LENGTH)));
let userHasEdited = $state(false);
// Intent-revealing alias used by both the persistent aria-live announcer and
// the visible empty-state copy. Folding the duplicated rule into one $derived
// keeps the two branches in lockstep. Felix #3 on PR #629 round 4.
const isQueryEmpty = $derived(searchQuery.trim() === '');
// Mirror the editor's typed text until the user takes ownership.
//
// Why `$state + $effect` (not `$derived`): `searchQuery` is also written by
// `bind:value` on the <input> below, so it needs to be a mutable `$state`.
// A `$derived` would be read-only and would clobber direct user edits on
// every editor keystroke. The `userHasEdited` latch pins ownership once the
// user types into the input. Felix #1 on PR #629.
$effect(() => {
if (!userHasEdited) {
searchQuery = editorQuery.slice(0, MAX_QUERY_LENGTH);
}
});
// Fire onSearch whenever the effective query changes — covers both the
// editor mirror and direct input edits. This is the only place onSearch
// fires; when the dropdown is unmounted, the effect is disposed and no
// further fetches occur.
$effect(() => {
onSearch(searchQuery);
});
// highlightedIndex must be both writable (keyboard handler mutates it) and // highlightedIndex must be both writable (keyboard handler mutates it) and
// reset when `items` changes (so it never points past the end of a new list). // reset when `items` changes (so it never points past the end of a new list).
@@ -162,70 +112,16 @@ function selectItem(item: Person) {
unauthenticated users. unauthenticated users.
--> -->
<div <div
class="fixed z-50 w-72 max-w-[calc(100vw-1rem)] overflow-hidden rounded-sm border border-line bg-surface shadow-lg" class="fixed z-50 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox" role="listbox"
aria-label={m.person_mention_btn_label()} aria-label={m.person_mention_btn_label()}
style:top={position.top} style:top={position.top}
style:bottom={position.bottom} style:bottom={position.bottom}
style:left={position.left} style:left={position.left}
> >
<div class="border-b border-line px-3 py-2">
<label class="sr-only" for="mention-search">{m.person_mention_search_label()}</label>
<div class="flex items-center gap-2">
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="h-5 w-5 shrink-0 text-ink-2"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" stroke-linecap="round" />
</svg>
<input
id="mention-search"
type="search"
data-test-search-input
maxlength={MAX_QUERY_LENGTH}
class="min-h-[44px] w-full bg-transparent font-sans text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-inset"
placeholder={m.person_mention_search_prompt()}
bind:value={searchQuery}
oninput={() => {
userHasEdited = true;
}}
onmousedown={(e) => e.stopPropagation()}
/>
</div>
</div>
<!--
Persistent aria-live region — lives ABOVE the conditional branches so the
element never unmounts when items transition between empty and populated.
VoiceOver in particular swallows announcements from freshly-mounted live
regions, and the previous (conditional-inside) markup silently dropped
the "N persons found" announcement when results populated. Leonie #3 on
PR #629 round 3.
-->
<p class="sr-only" aria-live="polite">
{#if model.items.length === 0}
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
{:else if model.items.length === 1}
{m.person_mention_results_count_singular()}
{:else}
{m.person_mention_results_count_plural({ count: model.items.length })}
{/if}
</p>
{#if model.items.length === 0} {#if model.items.length === 0}
<!-- <p class="px-3 py-2.5 font-sans text-sm text-ink-3">
Visible empty-state copy — visual-only. The persistent sr-only <p> {m.person_mention_popup_empty()}
above is the sole AT announcer; this one is hidden from screen readers
via aria-hidden="true" so VoiceOver does not double-announce
(NVDA de-dups, VoiceOver does not). Leonie S-2 on PR #629 round 4.
Do NOT add an aria-live attribute here — that would re-introduce
the duplicate announcement.
-->
<p aria-hidden="true" class="px-3 py-2.5 font-sans text-sm text-ink-3">
{isQueryEmpty ? m.person_mention_search_prompt() : m.person_mention_popup_empty()}
</p> </p>
<!-- <!--
Empty-state escape hatch — without it the transcriber has to close Empty-state escape hatch — without it the transcriber has to close
@@ -236,7 +132,7 @@ function selectItem(item: Person) {
<a <a
href="/persons/new" href="/persons/new"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener"
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none" class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
onmousedown={(e) => e.preventDefault()} onmousedown={(e) => e.preventDefault()}
> >

View File

@@ -1,37 +1,22 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page } from 'vitest/browser';
import { flushSync, mount, tick, unmount } from 'svelte';
import MentionDropdown from './MentionDropdown.svelte'; import MentionDropdown from './MentionDropdown.svelte';
import MentionDropdownFixture from './MentionDropdown.test-fixture.svelte';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
afterEach(cleanup); afterEach(cleanup);
const makePerson = (id: string, name: string, overrides: Partial<Person> = {}): Person => { const makePerson = (id: string, name: string, overrides: Record<string, unknown> = {}) => ({
const parts = name.split(' '); id,
return { firstName: name.split(' ')[0] ?? null,
id, lastName: name.split(' ').slice(1).join(' ') || name,
firstName: parts[0], displayName: name,
lastName: parts.slice(1).join(' ') || name, birthYear: null as number | null,
displayName: name, deathYear: null as number | null,
personType: 'PERSON', ...overrides
familyMember: false, });
...overrides
};
};
type DropdownState = { const baseModel = (overrides: Record<string, unknown> = {}) => ({
items: Person[]; items: [] as ReturnType<typeof makePerson>[],
command: (item: Person) => void;
clientRect: (() => DOMRect | null) | null;
};
const baseModel = (overrides: Partial<DropdownState> = {}): DropdownState => ({
items: [],
command: vi.fn(), command: vi.fn(),
clientRect: () => new DOMRect(100, 100, 0, 24), clientRect: () => new DOMRect(100, 100, 0, 24),
...overrides ...overrides
@@ -44,32 +29,14 @@ describe('MentionDropdown', () => {
await expect.element(page.getByRole('listbox', { name: /person verlinken/i })).toBeVisible(); await expect.element(page.getByRole('listbox', { name: /person verlinken/i })).toBeVisible();
}); });
it('shows the "enter a name" prompt when the search field is empty', async () => { it('renders the empty placeholder when items is empty', async () => {
render(MentionDropdown, { props: { model: baseModel() } }); render(MentionDropdown, { props: { model: baseModel() } });
// Scope to the visible empty-state <p> (text-ink-3) — the persistent await expect.element(page.getByText('Keine Personen gefunden')).toBeVisible();
// sr-only aria-live region above also contains the same prompt copy.
const visibleEmptyP = document.querySelector(
'[role="listbox"] p.text-ink-3'
) as HTMLElement | null;
expect(visibleEmptyP).not.toBeNull();
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt());
expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_popup_empty());
});
it('shows "no persons found" when the search has a query but the list is empty', async () => {
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } });
const visibleEmptyP = document.querySelector(
'[role="listbox"] p.text-ink-3'
) as HTMLElement | null;
expect(visibleEmptyP).not.toBeNull();
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
expect(visibleEmptyP!.textContent ?? '').not.toContain(m.person_mention_search_prompt());
}); });
it('shows the create-new escape hatch link in the empty state', async () => { it('shows the create-new escape hatch link in the empty state', async () => {
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'unknown' } }); render(MentionDropdown, { props: { model: baseModel() } });
const link = (await page const link = (await page
.getByRole('link', { name: /neue person anlegen/i }) .getByRole('link', { name: /neue person anlegen/i })
@@ -77,7 +44,6 @@ describe('MentionDropdown', () => {
expect(link.href).toContain('/persons/new'); expect(link.href).toContain('/persons/new');
expect(link.target).toBe('_blank'); expect(link.target).toBe('_blank');
expect(link.rel).toContain('noopener'); expect(link.rel).toContain('noopener');
expect(link.rel).toContain('noreferrer');
}); });
it('renders one option per item when populated', async () => { it('renders one option per item when populated', async () => {
@@ -138,315 +104,3 @@ describe('MentionDropdown', () => {
expect(dropdown.style.left).toBe('123px'); expect(dropdown.style.left).toBe('123px');
}); });
}); });
// ─── Search input — Issue #380 ────────────────────────────────────────────────
describe('MentionDropdown — search input', () => {
it('renders a search input pre-filled with the editorQuery prop', async () => {
render(MentionDropdown, {
props: { model: baseModel(), editorQuery: 'WdG' }
});
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
});
it('exposes a data-test-search-input attribute for E2E selectors', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const input = document.querySelector('[data-test-search-input]');
expect(input).not.toBeNull();
expect((input as HTMLInputElement).type).toBe('search');
});
it('search input wrapper meets the 44px touch target (WCAG 2.2 AA)', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const input = document.querySelector('[data-test-search-input]') as HTMLElement;
expect(input).not.toBeNull();
expect(input.className).toContain('min-h-[44px]');
});
it('renders a persistent aria-live="polite" region (does not remount on items transition; Leonie #3 on PR #629)', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const listbox = document.querySelector('[role="listbox"]');
expect(listbox).not.toBeNull();
const live = listbox!.querySelector('p[aria-live="polite"]');
expect(live).not.toBeNull();
// Empty + empty-query → "Namen eingeben…" prompt
expect(live!.textContent ?? '').toContain(m.person_mention_search_prompt());
});
it('announces the result count in the persistent live region when items populate (Leonie #3 on PR #629)', async () => {
render(MentionDropdown, {
props: {
model: baseModel({
items: [
makePerson('p1', 'Anna Schmidt'),
makePerson('p2', 'Bert Meier'),
makePerson('p3', 'Carl Vogel')
]
})
}
});
const listbox = document.querySelector('[role="listbox"]');
expect(listbox).not.toBeNull();
const live = listbox!.querySelector('p[aria-live="polite"]');
expect(live).not.toBeNull();
// Populated → "3 Personen gefunden" (plural)
expect(live!.textContent ?? '').toContain('3');
});
it('announces the singular form when exactly one item is present (Sara #4 on PR #629)', async () => {
render(MentionDropdown, {
props: {
model: baseModel({
items: [makePerson('p1', 'Anna Schmidt')]
})
}
});
const listbox = document.querySelector('[role="listbox"]');
expect(listbox).not.toBeNull();
const live = listbox!.querySelector('p[aria-live="polite"]');
expect(live).not.toBeNull();
// Singular branch — "1 Person gefunden" / "1 person found" / "1 persona encontrada"
// (locale-dependent; resolved via the Paraglide message helper).
expect(live!.textContent ?? '').toContain(m.person_mention_results_count_singular());
});
it('keeps the visible empty-state copy without its own aria-live and hides it from AT (Leonie #3 on PR #629 round 3; Leonie S-2 round 4)', async () => {
render(MentionDropdown, { props: { model: baseModel(), editorQuery: 'WdG' } });
// Visible empty-state <p> exists with the empty-result copy ...
const empty = document.querySelector('p.text-ink-3') as HTMLElement | null;
expect(empty).not.toBeNull();
expect(empty!.textContent ?? '').toContain(m.person_mention_popup_empty());
// ... but it must NOT carry its own aria-live (the persistent sr-only
// region above the conditional is the announcer now).
expect(empty!.hasAttribute('aria-live')).toBe(false);
// ... and it MUST be hidden from screen readers via aria-hidden="true"
// so VoiceOver does not double-announce (the persistent sr-only region
// is the sole AT source of truth). Leonie S-2 on PR #629 round 4.
expect(empty!.getAttribute('aria-hidden')).toBe('true');
});
it('renders the magnifier icon at h-5 w-5 with text-ink-2 (Leonie BLOCKER on PR #629)', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const icon = document.querySelector('[data-test-search-input]')
?.previousElementSibling as SVGElement | null;
expect(icon).not.toBeNull();
expect(icon!.tagName.toLowerCase()).toBe('svg');
expect(icon!.getAttribute('class') ?? '').toContain('h-5');
expect(icon!.getAttribute('class') ?? '').toContain('w-5');
expect(icon!.getAttribute('class') ?? '').toContain('text-ink-2');
});
it('caps the search input at maxlength=100 (CWE-400 amplification — Nora on PR #629)', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
expect(input).not.toBeNull();
expect(input.maxLength).toBe(100);
});
it('clips a long editorQuery mirror to 100 chars (CWE-400 layered — Nora #1 on PR #629)', async () => {
const longQuery = 'A'.repeat(200);
render(MentionDropdown, { props: { model: baseModel(), editorQuery: longQuery } });
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
expect(input).not.toBeNull();
expect(input.value.length).toBe(100);
expect(input.value).toBe('A'.repeat(100));
});
it('caps the listbox width to the viewport (320 px reflow guard — Leonie FINDING-MENTION-005)', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const listbox = document.querySelector('[role="listbox"]') as HTMLElement;
expect(listbox).not.toBeNull();
expect(listbox.className).toContain('max-w-[calc(100vw-1rem)]');
});
it('renders the @mention search input at text-base (16 px senior-audience floor — Leonie FINDING-MENTION-006)', async () => {
render(MentionDropdown, { props: { model: baseModel() } });
const input = document.querySelector('[data-test-search-input]') as HTMLInputElement;
expect(input).not.toBeNull();
expect(input.className).toContain('text-base');
expect(input.className).not.toContain('text-sm');
});
it('invokes onSearch with the current value whenever the user types', async () => {
const onSearch = vi.fn();
render(MentionDropdown, { props: { model: baseModel(), onSearch } });
await userEvent.type(page.getByRole('searchbox'), 'Walter');
await vi.waitFor(() => {
expect(onSearch).toHaveBeenCalled();
expect(onSearch).toHaveBeenLastCalledWith('Walter');
});
});
it('keeps the user-edited search value when editorQuery changes after the takeover (Felix on PR #629)', async () => {
let setEditorQuery!: (q: string) => void;
render(MentionDropdownFixture, {
model: baseModel(),
initialEditorQuery: 'WdG',
onReady: (s: (q: string) => void) => {
setEditorQuery = s;
}
});
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
await page.getByRole('searchbox').fill('Walter');
await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');
setEditorQuery('WdGruyter');
// Flush pending Svelte reactivity so any (non-)update from the mirror
// $effect has landed before we assert. expect.element already polls, so
// no fixed-timeout fallback is needed. Sara on PR #629 round 3.
await tick();
await expect.element(page.getByRole('searchbox')).toHaveValue('Walter');
});
});
// ─── ArrowDown via exported onKeyDown (Sara #3 on PR #629) ──────────────────
//
// In production, Tiptap intercepts ArrowDown/ArrowUp/Enter at the editor level
// and forwards them to the dropdown via its exported onKeyDown(event) function
// — the dropdown itself has no DOM keydown listener. This test exercises the
// same export so a regression in highlightedIndex/selection logic is caught
// at the unit level. The full E2E focus-chain test is deferred to a separate
// issue (Playwright).
//
// These unit tests directly invoke the exported `onKeyDown` to pin its
// behaviour in isolation. They do NOT exercise the Tiptap forwarding
// chain (PersonMentionEditor.suggestion.render() returning { onKeyDown })
// — that integration is covered by the 'ArrowDown moves the highlight'
// test in PersonMentionEditor.svelte.spec.ts. Sara on PR #629 round 3.
describe('MentionDropdown — onKeyDown forwarding', () => {
// flushSync ensures Svelte reactivity propagation completes before
// asserting (uniform across all four key tests so the next reader
// doesn't have to figure out why some are wrapped and others aren't).
// Felix #1 suggestion on PR #629 round 3.
it('ArrowDown advances aria-selected to the next option in the listbox', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const instance = mount(MentionDropdown, {
target: container,
props: {
model: baseModel({
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
})
}
});
try {
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
// First option starts highlighted.
const first = container.querySelector('[data-test-person-id="p1"]') as HTMLElement;
const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement;
expect(first.getAttribute('aria-selected')).toBe('true');
expect(second.getAttribute('aria-selected')).toBe('false');
let consumed = false;
flushSync(() => {
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
});
expect(consumed).toBe(true);
expect(first.getAttribute('aria-selected')).toBe('false');
expect(second.getAttribute('aria-selected')).toBe('true');
} finally {
unmount(instance);
container.remove();
}
});
it('ArrowUp wraps from the first option to the last', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const instance = mount(MentionDropdown, {
target: container,
props: {
model: baseModel({
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')]
})
}
});
try {
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
let consumed = false;
flushSync(() => {
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
});
expect(consumed).toBe(true);
const second = container.querySelector('[data-test-person-id="p2"]') as HTMLElement;
expect(second.getAttribute('aria-selected')).toBe('true');
} finally {
unmount(instance);
container.remove();
}
});
it('Enter invokes model.command with the currently highlighted item', async () => {
const command = vi.fn();
const container = document.createElement('div');
document.body.appendChild(container);
const instance = mount(MentionDropdown, {
target: container,
props: {
model: baseModel({
items: [makePerson('p1', 'Anna Schmidt'), makePerson('p2', 'Bert Meier')],
command
})
}
});
try {
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
let consumed = false;
flushSync(() => {
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }));
});
expect(consumed).toBe(true);
expect(command).toHaveBeenCalledTimes(1);
expect(command.mock.calls[0][0].id).toBe('p1');
} finally {
unmount(instance);
container.remove();
}
});
it('Escape returns false so the suggestion plugin can handle it', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const instance = mount(MentionDropdown, {
target: container,
props: {
model: baseModel({ items: [makePerson('p1', 'Anna Schmidt')] })
}
});
try {
const exports = instance as unknown as { onKeyDown: (e: KeyboardEvent) => boolean };
let consumed = true;
flushSync(() => {
consumed = exports.onKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }));
});
expect(consumed).toBe(false);
} finally {
unmount(instance);
container.remove();
}
});
});

View File

@@ -1,32 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import MentionDropdown from './MentionDropdown.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type DropdownState = {
items: Person[];
command: (item: Person) => void;
clientRect: (() => DOMRect | null) | null;
};
type Props = {
model: DropdownState;
initialEditorQuery: string;
/** Test hook: receives a setter for editorQuery so the test can mutate it. */
onReady?: (setEditorQuery: (q: string) => void) => void;
onSearch?: (q: string) => void;
};
let { model, initialEditorQuery, onReady, onSearch = () => {} }: Props = $props();
let editorQuery = $state(untrack(() => initialEditorQuery));
$effect(() => {
onReady?.((q) => {
editorQuery = q;
});
});
</script>
<MentionDropdown model={model} editorQuery={editorQuery} onSearch={onSearch} />

View File

@@ -7,9 +7,7 @@ import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import type { PersonMention } from '$lib/shared/types'; import type { PersonMention } from '$lib/shared/types';
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer'; import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
import { debounce } from '$lib/shared/utils/debounce';
import MentionDropdown from './MentionDropdown.svelte'; import MentionDropdown from './MentionDropdown.svelte';
import { MAX_QUERY_LENGTH, SEARCH_DEBOUNCE_MS, SEARCH_RESULT_LIMIT } from './mentionConstants';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
@@ -35,13 +33,6 @@ let {
let editorEl: HTMLDivElement; let editorEl: HTMLDivElement;
let editor: Editor | null = null; let editor: Editor | null = null;
// Hoisted so onDestroy can guarantee the imperatively-mounted dropdown is
// torn down even if Tiptap's suggestion plugin onExit didn't fire (e.g. when
// the host component is unmounted while the dropdown is still open).
let mountedDropdown: object | null = null;
// Hoisted so onDestroy can cancel any pending fetch — otherwise a trailing
// debounced search can fire after the editor is gone and pollute later tests.
let cancelPendingSearch: (() => void) | null = null;
// Single reactive state object shared with MentionDropdown. Mutating these // Single reactive state object shared with MentionDropdown. Mutating these
// fields propagates to the mounted dropdown via Svelte's $state proxy — // fields propagates to the mounted dropdown via Svelte's $state proxy —
@@ -51,12 +42,10 @@ let dropdownState = $state<{
items: Person[]; items: Person[];
command: (item: Person) => void; command: (item: Person) => void;
clientRect: (() => DOMRect | null) | null; clientRect: (() => DOMRect | null) | null;
editorQuery: string;
}>({ }>({
items: [], items: [],
command: () => {}, command: () => {},
clientRect: null, clientRect: null
editorQuery: ''
}); });
type DropdownExports = { type DropdownExports = {
@@ -149,13 +138,16 @@ onMount(() => {
// Nora #5618 #3 — separate issue tracks the GET /api/persons // Nora #5618 #3 — separate issue tracks the GET /api/persons
// response-shape audit (PersonSummaryDTO leaks `notes`). // response-shape audit (PersonSummaryDTO leaks `notes`).
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Tiptap's suggestion plugin requires an `items()` callback to keep items: async ({ query }: { query: string }) => {
// the dropdown alive, but the actual fetch is owned by `runSearch` if (!query) return [];
// below — routed through the dropdown's search input via the try {
// debounced `onSearch` channel. Returning `[]` here keeps Tiptap const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
// happy without firing a duplicate per-keystroke fetch. if (!res.ok) return [];
// Markus #5616 / Felix / Nora / Sara on PR #629. return ((await res.json()) as Person[]).slice(0, 5);
items: async () => [], } catch {
return [];
}
},
// AC-1 fix: insert the typed query as displayName, not person.displayName. // AC-1 fix: insert the typed query as displayName, not person.displayName.
command({ editor: ed, range, props }) { command({ editor: ed, range, props }) {
const p = props as unknown as { personId: string; displayName: string }; const p = props as unknown as { personId: string; displayName: string };
@@ -173,6 +165,7 @@ onMount(() => {
.run(); .run();
}, },
render() { render() {
let component: object | null = null;
let exports: DropdownExports | null = null; let exports: DropdownExports | null = null;
// Tiptap's SuggestionProps types `command` against the default // Tiptap's SuggestionProps types `command` against the default
@@ -185,84 +178,25 @@ onMount(() => {
clientRect?: (() => DOMRect | null) | null; clientRect?: (() => DOMRect | null) | null;
}; };
// Request-token guard: every onSearch invocation bumps `requestId`;
// runSearch captures the id active when its fetch starts and discards
// the response if a newer onSearch has fired since. Without this, a
// late response can repopulate the dropdown after the user cleared
// the search input. Sara on PR #629.
let requestId = 0;
const runSearch = async (query: string) => {
const id = requestId;
try {
// Defensive client-side cap — server-side enforcement is tracked
// separately. Markus on PR #629.
const res = await fetch(
`/api/persons?q=${encodeURIComponent(query)}&limit=${SEARCH_RESULT_LIMIT}`
);
if (id !== requestId) return;
if (!res.ok) {
dropdownState.items = [];
return;
}
const data = (await res.json()) as Person[];
if (id !== requestId) return;
dropdownState.items = data.slice(0, SEARCH_RESULT_LIMIT);
} catch {
if (id !== requestId) return;
dropdownState.items = [];
}
};
const debouncedSearch = debounce(runSearch, SEARCH_DEBOUNCE_MS);
cancelPendingSearch = () => debouncedSearch.cancel();
const onSearch = (query: string) => {
requestId++;
if (query.trim() === '') {
debouncedSearch.cancel();
dropdownState.items = [];
return;
}
debouncedSearch(query);
};
const updateState = (renderProps: LooseRenderProps) => { const updateState = (renderProps: LooseRenderProps) => {
// Clip once here so both the inserted displayName and the dropdownState.items = renderProps.items as Person[];
// dropdown's editor-mirror see the same value. The dropdown
// already clips the mirror (Nora #1 CWE-400), but without
// clipping at the command boundary an unclipped query would
// still flow through as the inserted displayName — visible
// UI divergence between "what I searched" and "what was
// inserted". Felix #3 on PR #629.
const clippedQuery = renderProps.query.slice(0, MAX_QUERY_LENGTH);
// AC-1: pass typed query as displayName, not person.displayName // AC-1: pass typed query as displayName, not person.displayName
dropdownState.command = (item: Person) => dropdownState.command = (item: Person) =>
renderProps.command({ renderProps.command({
personId: item.id, personId: item.id,
displayName: clippedQuery displayName: renderProps.query
}); });
dropdownState.clientRect = renderProps.clientRect ?? null; dropdownState.clientRect = renderProps.clientRect ?? null;
dropdownState.editorQuery = clippedQuery;
}; };
return { return {
onStart(renderProps) { onStart(renderProps) {
const loose = renderProps as unknown as LooseRenderProps; updateState(renderProps as unknown as LooseRenderProps);
updateState(loose);
// MentionDropdown reads `editorQuery` off the shared state
// proxy via its `editorQuery` prop binding below — this is
// the same pattern as `model.items`. We do not pass it as a
// separate prop because Svelte 5's mount() does not expose
// settable prop accessors, so we route through the proxy.
const mounted = mount(MentionDropdown, { const mounted = mount(MentionDropdown, {
target: document.body, target: document.body,
props: { props: { model: dropdownState }
model: dropdownState,
get editorQuery() {
return dropdownState.editorQuery;
},
onSearch
}
}); });
mountedDropdown = mounted as object; component = mounted as object;
exports = mounted as unknown as DropdownExports; exports = mounted as unknown as DropdownExports;
}, },
onUpdate(renderProps) { onUpdate(renderProps) {
@@ -274,16 +208,9 @@ onMount(() => {
return exports?.onKeyDown(event) ?? false; return exports?.onKeyDown(event) ?? false;
}, },
onExit() { onExit() {
// Cancel any pending debounce so a closed dropdown's trailing if (component) {
// runSearch cannot fire against the *next* dropdown's state. unmount(component);
// The hoisted `cancelPendingSearch` would be overwritten by component = null;
// the next render()'s onStart before the trailing call fires,
// so we cancel locally via the closure-scoped debouncedSearch.
// Felix #1 on PR #629.
debouncedSearch.cancel();
if (mountedDropdown) {
unmount(mountedDropdown);
mountedDropdown = null;
exports = null; exports = null;
} }
} }
@@ -326,15 +253,7 @@ onMount(() => {
}); });
onDestroy(() => { onDestroy(() => {
cancelPendingSearch?.();
editor?.destroy(); editor?.destroy();
// Tiptap suggestion onExit usually unmounts the dropdown, but if the host
// component is destroyed while a suggestion is active the dropdown can
// outlive the editor — clean it up explicitly.
if (mountedDropdown) {
unmount(mountedDropdown);
mountedDropdown = null;
}
}); });
// Keep the data-placeholder attribute in sync with actual emptiness so the // Keep the data-placeholder attribute in sync with actual emptiness so the

View File

@@ -8,45 +8,29 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import { tick } from 'svelte'; import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
import PersonMentionEditorHost from './PersonMentionEditor.test-fixture.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
// Single source of truth for the debounce window — imported from the shared
// module so the test cannot drift from production. Sara on PR #629 round 3.
import { SEARCH_DEBOUNCE_MS } from './mentionConstants';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention']; type PersonMention = components['schemas']['PersonMention'];
/**
* Headroom above SEARCH_DEBOUNCE_MS for the debounce-window wait
* assertions in this file. 350 ms is calibrated against CI-runner jitter
* we observed pre-#629; dropping it below ~200 ms reintroduces flake.
* See PR #629 round-2 review comment #10935 (Sara).
*/
const POST_DEBOUNCE_SLACK_MS = 350;
const AUGUSTE: Person = { const AUGUSTE: Person = {
id: 'p-aug', id: 'p-aug',
firstName: 'Auguste', firstName: 'Auguste',
lastName: 'Raddatz', lastName: 'Raddatz',
displayName: 'Auguste Raddatz', displayName: 'Auguste Raddatz',
personType: 'PERSON',
familyMember: false,
birthYear: 1882, birthYear: 1882,
deathYear: 1944 deathYear: 1944
}; } as unknown as Person;
const ANNA: Person = { const ANNA: Person = {
id: 'p-anna', id: 'p-anna',
firstName: 'Anna', firstName: 'Anna',
lastName: 'Schmidt', lastName: 'Schmidt',
displayName: 'Anna Schmidt', displayName: 'Anna Schmidt',
personType: 'PERSON',
familyMember: false,
birthYear: 1860 birthYear: 1860
}; } as unknown as Person;
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
vi.stubGlobal( vi.stubGlobal(
@@ -141,20 +125,6 @@ describe('PersonMentionEditor — typeahead', () => {
}); });
}); });
it('appends &limit=5 to the fetch URL (defensive client-side cap, Markus on PR #629)', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('limit=5'));
});
});
it('shows life dates next to the name in the dropdown', async () => { it('shows life dates next to the name in the dropdown', async () => {
mockFetchWithPersons(); mockFetchWithPersons();
renderHost(); renderHost();
@@ -172,15 +142,8 @@ describe('PersonMentionEditor — typeahead', () => {
await userEvent.type(page.getByRole('textbox'), '@xyz'); await userEvent.type(page.getByRole('textbox'), '@xyz');
// The visible empty-state <p> (text-ink-3) shows the copy. The persistent await vi.waitFor(async () => {
// sr-only aria-live region also contains the same copy, so we scope to the await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
// visible element to avoid a multi-match resolution in expect.element.
await vi.waitFor(() => {
const visibleEmptyP = document.querySelector(
'[role="listbox"] p.text-ink-3'
) as HTMLElement | null;
expect(visibleEmptyP).not.toBeNull();
expect(visibleEmptyP!.textContent ?? '').toContain('Keine Personen gefunden');
}); });
}); });
@@ -198,254 +161,6 @@ describe('PersonMentionEditor — typeahead', () => {
}); });
}); });
// ─── AC-2/3: search input drives the person fetch (debounced) ───────────────
describe('PersonMentionEditor — AC-2/3: search input drives fetch', () => {
it('editing the search input fires a debounced fetch with the new query', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
// Open the dropdown so the search input is reachable.
await userEvent.type(page.getByRole('textbox'), '@');
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
const fetchesBeforeSearch = fetchMock.mock.calls.length;
// `fill` simulates a single input event with the final value — sidesteps
// per-keystroke timing of userEvent.type so the test can deterministically
// assert that one input event collapses into one debounced fetch.
await page.getByRole('searchbox').fill('Walter');
await vi.waitFor(
() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=Walter'));
},
{ timeout: 1000 }
);
const fetchesAfterSearch = fetchMock.mock.calls.length - fetchesBeforeSearch;
expect(fetchesAfterSearch).toBe(1);
});
it('fires exactly one /api/persons fetch when the user searches for Walter (regression guard)', async () => {
// Regression guard: a previous version of PersonMentionEditor had a
// duplicated `items()` callback in the Tiptap suggestion config that
// fetched per-keystroke in addition to the debounced search-input fetch
// (Markus & Felix round-1). To catch that regression, we must NOT
// subtract any baseline — every fetch from render onwards counts.
// Sara on PR #629 round 3.
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
// Open the dropdown, then drive the search input via fill() — sidesteps
// per-keystroke timing of userEvent.type that Sara flagged round 2.
await userEvent.type(page.getByRole('textbox'), '@');
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
await page.getByRole('searchbox').fill('Walter');
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
// No baseline subtraction — count ALL /api/persons fetches since render.
// If the legacy per-keystroke items() callback returns, typing `@` alone
// would already produce one fetch and `fill('Walter')` another, breaking
// this assertion.
const personsFetches = fetchMock.mock.calls.filter(
([url]) => typeof url === 'string' && url.startsWith('/api/persons')
);
expect(personsFetches.length).toBe(1);
});
it('clearing the search input clears the list without firing a fetch', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(async () => {
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
});
const fetchesBeforeClear = fetchMock.mock.calls.length;
await userEvent.clear(page.getByRole('searchbox'));
// Negative assertion: wait past the debounce window to confirm no
// trailing fetch was scheduled. Removing this wait would mask a
// re-introduction of the keystroke-driven items() fetch.
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
expect(fetchMock.mock.calls.length).toBe(fetchesBeforeClear);
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
});
});
// ─── Whitespace-only query (Elicit AC-4 ambiguity on PR #629) ───────────────
describe('PersonMentionEditor — whitespace-only query', () => {
it('keeps the "Namen eingeben…" prompt and fires no fetch when @ is followed only by spaces', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
await userEvent.type(page.getByRole('textbox'), '@ ');
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
// Scope to the visible empty-state <p> (text-ink-3) — the persistent
// sr-only aria-live region above contains the same copy.
const visibleEmptyP = document.querySelector(
'[role="listbox"] p.text-ink-3'
) as HTMLElement | null;
expect(visibleEmptyP).not.toBeNull();
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_search_prompt());
expect(fetchMock).not.toHaveBeenCalled();
});
});
// ─── Stale-response race (Sara on PR #629) ───────────────────────────────────
describe('PersonMentionEditor — stale-response race', () => {
it('discards a stale response that resolves after the search has been cleared', async () => {
let resolveFetch!: (v: { ok: boolean; json: () => Promise<Person[]> }) => void;
const pendingResponse = new Promise<{ ok: boolean; json: () => Promise<Person[]> }>((r) => {
resolveFetch = r;
});
const fetchMock = vi.fn().mockReturnValue(pendingResponse);
vi.stubGlobal('fetch', fetchMock);
renderHost();
// Open the dropdown and let the debounce fire so a fetch is in flight.
await userEvent.type(page.getByRole('textbox'), '@Aug');
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
});
// Clear the search input *before* the fetch resolves.
await userEvent.clear(page.getByRole('searchbox'));
await expect.element(page.getByRole('searchbox')).toHaveValue('');
// The stale fetch now resolves with persons. The dropdown must stay empty.
resolveFetch({ ok: true, json: () => Promise.resolve([AUGUSTE]) });
// Flush pending Svelte reactivity so any (non-)update from the stale
// fetch resolution has landed before we assert. expect.element already
// polls, so no fixed-timeout fallback is needed. Sara on PR #629 round 4.
await tick();
await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
});
});
// ─── Server failure characterization (Sara #2 on PR #629) ───────────────────
describe('PersonMentionEditor — server failure', () => {
it('on 500 response keeps the dropdown open with the empty-state copy (silent failure pinned; distinct error UX tracked separately)', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: false, status: 500, json: vi.fn().mockResolvedValue({}) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
// Pins current silent-failure behaviour. The day someone implements a
// distinct error UX (toast / "Suche fehlgeschlagen" copy), this test
// goes red and forces them to update the assertion. Scope to the
// visible <p> (text-ink-3) — the persistent sr-only live region
// above contains the same copy.
const visibleEmptyP = document.querySelector(
'[role="listbox"] p.text-ink-3'
) as HTMLElement | null;
expect(visibleEmptyP).not.toBeNull();
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
});
it('on a fetch reject (network failure) keeps the dropdown open with the empty-state copy', async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError('NetworkError'));
vi.stubGlobal('fetch', fetchMock);
renderHost();
await userEvent.type(page.getByRole('textbox'), '@Aug');
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
const visibleEmptyP = document.querySelector(
'[role="listbox"] p.text-ink-3'
) as HTMLElement | null;
expect(visibleEmptyP).not.toBeNull();
expect(visibleEmptyP!.textContent ?? '').toContain(m.person_mention_popup_empty());
});
});
// ─── onExit cancels pending debounce (Felix #1 on PR #629) ───────────────────
describe('PersonMentionEditor — onExit cancels pending debounce', () => {
it('cancels the pending debounced fetch when Escape closes the dropdown before the debounce fires', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
vi.stubGlobal('fetch', fetchMock);
renderHost();
// Open the dropdown by typing @ + a query in the editor.
await userEvent.type(page.getByRole('textbox'), '@A');
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toBeVisible();
});
// Wait for any in-flight fetch from opening the dropdown to settle.
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
const fetchesBeforeEscape = fetchMock.mock.calls.length;
// Trigger a new debounced search (queues runSearch after 150 ms), then
// immediately Escape *while focus is back in the editor* so Tiptap's
// suggestion-plugin Escape handler fires onExit before the debounce.
// Without onExit cancelling the pending debounce, runSearch executes
// against the now-unmounted dropdown's state.
await page.getByRole('searchbox').fill('Walter');
// Focus the editor so the Escape lands on Tiptap's suggestion handler.
(page.getByRole('textbox').element() as HTMLElement).focus();
await userEvent.keyboard('{Escape}');
// Wait past the debounce window. If onExit did not cancel the pending
// debounce, a fetch with q=Walter would still fire here.
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape);
const walterFetches = newFetches.filter(
([url]) => typeof url === 'string' && url.includes('q=Walter')
);
expect(walterFetches.length).toBe(0);
});
});
// ─── AC-1: search input prefilled with text typed after @ ───────────────────
describe('PersonMentionEditor — AC-1: search input prefill', () => {
it('prefills the dropdown search input with the text typed after @', async () => {
mockFetchEmpty();
renderHost();
await userEvent.type(page.getByRole('textbox'), '@WdG');
await vi.waitFor(async () => {
await expect.element(page.getByRole('searchbox')).toHaveValue('WdG');
});
});
});
// ─── AC-1: typed text becomes displayName, not DB name ─────────────────────── // ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
describe('PersonMentionEditor — AC-1: typed text as displayName', () => { describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
@@ -514,39 +229,6 @@ describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
}); });
}); });
it('clips the inserted displayName to MAX_QUERY_LENGTH=100 chars (Felix #3 on PR #629)', async () => {
// CWE-400 amplification: the dropdown clips its search input + mirror at
// 100 chars (Nora #1), but the host editor was passing the unclipped
// renderProps.query straight through to displayName — so a 105-char
// @-suffix in the editor could insert a 105-char displayName into the
// sidecar even though the dropdown only searched the first 100.
mockFetchWithPersons();
const host = renderHost();
// Type @ + 105 'A' chars in the contenteditable. The renderProps.query
// fed into the command callback derives from the editor text after `@`,
// not the dropdown's searchbox — so we must drive the editor.
await userEvent.type(page.getByRole('textbox'), '@' + 'A'.repeat(105));
// The mocked /api/persons returns AUGUSTE for any query — wait for it.
await vi.waitFor(async () => {
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
});
const option = (await page
.getByRole('option', { name: /Auguste Raddatz/ })
.element()) as HTMLElement;
option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(host.snapshot.mentionedPersons).toHaveLength(1);
// Tight assertion: input is 105 chars, cap is exactly 100. Using
// `toHaveLength(100)` discriminates "clip works" from "clip works
// AND nothing weakened it to e.g. 95". Sara on PR #629 round 4.
expect(host.snapshot.mentionedPersons[0].displayName).toHaveLength(100);
});
});
it('does not duplicate the sidecar entry when the same person is selected twice', async () => { it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
mockFetchWithPersons(); mockFetchWithPersons();
const host = renderHost({ const host = renderHost({

View File

@@ -1,10 +0,0 @@
/** Shared knobs for the @mention typeahead. Single source of truth for
* the dropdown component and the host editor — keeps the layered length
* cap and the debounce window consistent across both files. */
export const MAX_QUERY_LENGTH = 100;
export const SEARCH_DEBOUNCE_MS = 150;
/** Defensive client-side cap on the result list. Single consumer today
* (PersonMentionEditor), kept here for symmetry with the other limit
* knobs so the @mention configuration lives in one place. Felix #1 on
* PR #629 round 4. */
export const SEARCH_RESULT_LIMIT = 5;

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, afterEach } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import TestHost from './confirm.test-fixture.svelte'; import TestHost from './confirm.test-host.svelte';
import type { ConfirmService } from './confirm.svelte.js'; import type { ConfirmService } from './confirm.svelte.js';
afterEach(cleanup); afterEach(cleanup);

View File

@@ -1,25 +1,12 @@
/** /**
* Returns a debounced version of fn that delays invocation until after * Returns a debounced version of fn that delays invocation until after
* `delay` ms have elapsed since the last call. The returned function * `delay` ms have elapsed since the last call.
* exposes a `cancel()` method that DROPS (does not flush) the pending
* trailing invocation — essential when the host context (a destroyed
* component, an unmounted editor) shouldn't fire the trailing call.
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function debounce<T extends (...args: any[]) => void>( export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
fn: T, let timer: ReturnType<typeof setTimeout>;
delay: number return ((...args: Parameters<T>) => {
): T & { cancel: () => void } { clearTimeout(timer);
let timer: ReturnType<typeof setTimeout> | undefined;
const wrapped = ((...args: Parameters<T>) => {
if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay); timer = setTimeout(() => fn(...args), delay);
}) as T & { cancel: () => void }; }) as T;
wrapped.cancel = () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
return wrapped;
} }

View File

@@ -4,7 +4,7 @@ import { page } from 'vitest/browser';
import DocumentList from './DocumentList.svelte'; import DocumentList from './DocumentList.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => cleanup()); afterEach(() => cleanup());

View File

@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
const { default: DocumentList } = await import('./DocumentList.svelte'); const { default: DocumentList } = await import('./DocumentList.svelte');

View File

@@ -1,11 +1,13 @@
import { describe, it, expect, afterEach, vi } from 'vitest'; import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import { invalidateAll } from '$app/navigation';
import DropZone from './DropZone.svelte'; import DropZone from './DropZone.svelte';
vi.mock('$app/navigation'); // vi.hoisted lets the mock fn reference survive vi.mock's hoisting so tests
// can assert on it from below while the factory remains self-contained.
const { invalidateAllMock } = vi.hoisted(() => ({ invalidateAllMock: vi.fn(async () => {}) }));
vi.mock('$app/navigation', () => ({ invalidateAll: invalidateAllMock }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -66,7 +68,7 @@ describe('DropZone onUploadComplete', () => {
// invalidateAll is the last async step of the upload handler — once it // invalidateAll is the last async step of the upload handler — once it
// has been called, the callback decision has already been made. // has been called, the callback decision has already been made.
await vi.waitFor(() => { await vi.waitFor(() => {
expect(vi.mocked(invalidateAll)).toHaveBeenCalled(); expect(invalidateAllMock).toHaveBeenCalled();
}); });
expect(onUploadComplete).not.toHaveBeenCalled(); expect(onUploadComplete).not.toHaveBeenCalled();
}); });

View File

@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
const { default: DropZone } = await import('./DropZone.svelte'); const { default: DropZone } = await import('./DropZone.svelte');

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
@@ -34,16 +34,16 @@ export async function load({ fetch, locals }) {
]); ]);
if (!usersResult.response.ok) { if (!usersResult.response.ok) {
throw error(usersResult.response.status, getErrorMessage(extractErrorCode(usersResult.error))); const code = (usersResult.error as unknown as { code?: string })?.code;
throw error(usersResult.response.status, getErrorMessage(code));
} }
if (!groupsResult.response.ok) { if (!groupsResult.response.ok) {
throw error( const code = (groupsResult.error as unknown as { code?: string })?.code;
groupsResult.response.status, throw error(groupsResult.response.status, getErrorMessage(code));
getErrorMessage(extractErrorCode(groupsResult.error))
);
} }
if (!tagsResult.response.ok) { if (!tagsResult.response.ok) {
throw error(tagsResult.response.status, getErrorMessage(extractErrorCode(tagsResult.error))); const code = (tagsResult.error as unknown as { code?: string })?.code;
throw error(tagsResult.response.status, getErrorMessage(code));
} }
let inviteCount = 0; let inviteCount = 0;

View File

@@ -1,6 +1,6 @@
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ params, parent }) => { export const load: PageServerLoad = async ({ params, parent }) => {
@@ -24,9 +24,8 @@ export const actions: Actions = {
}); });
if (!result.response.ok) { if (!result.response.ok) {
return fail(result.response.status, { const code = (result.error as unknown as { code?: string })?.code;
error: getErrorMessage(extractErrorCode(result.error)) return fail(result.response.status, { error: getErrorMessage(code) });
});
} }
return { success: true }; return { success: true };
@@ -39,9 +38,8 @@ export const actions: Actions = {
}); });
if (!result.response.ok) { if (!result.response.ok) {
return fail(result.response.status, { const code = (result.error as unknown as { code?: string })?.code;
error: getErrorMessage(extractErrorCode(result.error)) return fail(result.response.status, { error: getErrorMessage(code) });
});
} }
throw redirect(303, '/admin/groups'); throw redirect(303, '/admin/groups');

View File

@@ -7,8 +7,7 @@ const mockApi = {
}; };
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({
createApiClient: () => mockApi, createApiClient: () => mockApi
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
})); }));
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());

View File

@@ -5,7 +5,7 @@ import Page from './+page.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
vi.mock('$app/forms', () => ({ enhance: () => () => {} })); vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';

View File

@@ -1,10 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+layout.server'; import { load } from './+layout.server';
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { createApiClient } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';

View File

@@ -1,6 +1,6 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types'; import type { Actions } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
export const actions: Actions = { export const actions: Actions = {
@@ -16,9 +16,8 @@ export const actions: Actions = {
}); });
if (!result.response.ok) { if (!result.response.ok) {
return fail(result.response.status, { const code = (result.error as unknown as { code?: string })?.code;
error: getErrorMessage(extractErrorCode(result.error)) return fail(result.response.status, { error: getErrorMessage(code) });
});
} }
throw redirect(303, '/admin/groups'); throw redirect(303, '/admin/groups');

View File

@@ -11,7 +11,7 @@ vi.mock('$app/forms', () => ({
return { destroy: vi.fn() }; return { destroy: vi.fn() };
} }
})); }));
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';

View File

@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
const { default: AdminGroupNewPage } = await import('./+page.svelte'); const { default: AdminGroupNewPage } = await import('./+page.svelte');

View File

@@ -1,5 +1,5 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
@@ -25,7 +25,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
let invites: InviteListItem[] = []; let invites: InviteListItem[] = [];
let loadError: string | null = null; let loadError: string | null = null;
if (!invitesResult.response.ok) { if (!invitesResult.response.ok) {
loadError = extractErrorCode(invitesResult.error) ?? 'INTERNAL_ERROR'; const code = (invitesResult.error as unknown as { code?: string })?.code;
loadError = code ?? 'INTERNAL_ERROR';
} else { } else {
invites = (invitesResult.data ?? []) as InviteListItem[]; invites = (invitesResult.data ?? []) as InviteListItem[];
} }
@@ -33,7 +34,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
let groups: UserGroup[] = []; let groups: UserGroup[] = [];
let groupsLoadError: string | null = null; let groupsLoadError: string | null = null;
if (!groupsResult.response.ok) { if (!groupsResult.response.ok) {
groupsLoadError = extractErrorCode(groupsResult.error) ?? 'INTERNAL_ERROR'; const code = (groupsResult.error as unknown as { code?: string })?.code;
groupsLoadError = code ?? 'INTERNAL_ERROR';
} else { } else {
const raw = groupsResult.data ?? []; const raw = groupsResult.data ?? [];
groups = [...raw].sort((a, b) => a.name.localeCompare(b.name)); groups = [...raw].sort((a, b) => a.name.localeCompare(b.name));
@@ -60,9 +62,8 @@ export const actions = {
}); });
if (!result.response.ok) { if (!result.response.ok) {
return fail(result.response.status, { const code = (result.error as unknown as { code?: string })?.code;
createError: extractErrorCode(result.error) ?? 'INTERNAL_ERROR' return fail(result.response.status, { createError: code ?? 'INTERNAL_ERROR' });
});
} }
return { created: result.data! as InviteListItem }; return { created: result.data! as InviteListItem };
@@ -77,9 +78,8 @@ export const actions = {
const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } }); const result = await api.DELETE('/api/invites/{id}', { params: { path: { id } } });
if (!result.response.ok) { if (!result.response.ok) {
return fail(result.response.status, { const code = (result.error as unknown as { code?: string })?.code;
revokeError: extractErrorCode(result.error) ?? 'INTERNAL_ERROR' return fail(result.response.status, { revokeError: code ?? 'INTERNAL_ERROR' });
});
} }
return { revoked: id }; return { revoked: id };

View File

@@ -1,10 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+layout.server'; import { load } from './+layout.server';
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { createApiClient } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ fetch }) => { export const load: PageServerLoad = async ({ fetch }) => {
@@ -8,7 +8,8 @@ export const load: PageServerLoad = async ({ fetch }) => {
const result = await api.GET('/api/ocr/training-info'); const result = await api.GET('/api/ocr/training-info');
if (!result.response.ok) { if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
} }
return { trainingInfo: result.data! }; return { trainingInfo: result.data! };

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ params, fetch }) => { export const load: PageServerLoad = async ({ params, fetch }) => {
@@ -10,7 +10,8 @@ export const load: PageServerLoad = async ({ params, fetch }) => {
}); });
if (!result.response.ok) { if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
} }
return { history: result.data!, personId: params.personId }; return { history: result.data!, personId: params.personId };

View File

@@ -3,10 +3,7 @@ import { load } from './+page.server';
const mockApi = { GET: vi.fn() }; const mockApi = { GET: vi.fn() };
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
createApiClient: () => mockApi,
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ fetch }) => { export const load: PageServerLoad = async ({ fetch }) => {
@@ -8,7 +8,8 @@ export const load: PageServerLoad = async ({ fetch }) => {
const result = await api.GET('/api/ocr/training-info/global'); const result = await api.GET('/api/ocr/training-info/global');
if (!result.response.ok) { if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
} }
return { history: result.data! }; return { history: result.data! };

View File

@@ -3,10 +3,7 @@ import { load } from './+page.server';
const mockApi = { GET: vi.fn() }; const mockApi = { GET: vi.fn() };
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
createApiClient: () => mockApi,
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());

View File

@@ -3,10 +3,7 @@ import { load } from './+page.server';
const mockApi = { GET: vi.fn() }; const mockApi = { GET: vi.fn() };
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({ createApiClient: () => mockApi }));
createApiClient: () => mockApi,
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());

View File

@@ -8,7 +8,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import Page from './+page.svelte'; import Page from './+page.svelte';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
const fullData = { const fullData = {
userCount: 4, userCount: 4,

View File

@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
const { default: AdminEntryPage } = await import('./+page.svelte'); const { default: AdminEntryPage } = await import('./+page.svelte');

View File

@@ -1,6 +1,6 @@
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ params, parent, url }) => { export const load: PageServerLoad = async ({ params, parent, url }) => {
@@ -25,9 +25,8 @@ export const actions: Actions = {
}); });
if (!result.response.ok) { if (!result.response.ok) {
return fail(result.response.status, { const code = (result.error as unknown as { code?: string })?.code;
error: getErrorMessage(extractErrorCode(result.error)) return fail(result.response.status, { error: getErrorMessage(code) });
});
} }
return { success: true }; return { success: true };
@@ -44,9 +43,8 @@ export const actions: Actions = {
}); });
if (!result.response.ok) { if (!result.response.ok) {
return fail(result.response.status, { const code = (result.error as unknown as { code?: string })?.code;
error: getErrorMessage(extractErrorCode(result.error)) return fail(result.response.status, { error: getErrorMessage(code) });
});
} }
throw redirect(303, `/admin/tags/${result.data!.id}?merged=1`); throw redirect(303, `/admin/tags/${result.data!.id}?merged=1`);
@@ -67,9 +65,8 @@ export const actions: Actions = {
}); });
if (!result.response.ok) { if (!result.response.ok) {
return fail(result.response.status, { const code = (result.error as unknown as { code?: string })?.code;
error: getErrorMessage(extractErrorCode(result.error)) return fail(result.response.status, { error: getErrorMessage(code) });
});
} }
throw redirect(303, '/admin/tags'); throw redirect(303, '/admin/tags');

View File

@@ -8,8 +8,7 @@ const mockApi = {
}; };
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({
createApiClient: () => mockApi, createApiClient: () => mockApi
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
})); }));
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());

View File

@@ -5,7 +5,11 @@ import Page from './+page.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
vi.mock('$app/forms', () => ({ enhance: () => () => {} })); vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({
beforeNavigate: vi.fn(),
goto: vi.fn(),
replaceState: vi.fn()
}));
vi.mock('$app/stores', () => ({ vi.mock('$app/stores', () => ({
page: { page: {
subscribe: (fn: (v: { url: URL }) => void) => { subscribe: (fn: (v: { url: URL }) => void) => {

View File

@@ -17,7 +17,19 @@ vi.mock('$lib/shared/services/confirm.svelte', () => ({
getConfirmService: () => ({ confirm: async () => false }) getConfirmService: () => ({ confirm: async () => false })
})); }));
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
const { default: AdminTagEditPage } = await import('./+page.svelte'); const { default: AdminTagEditPage } = await import('./+page.svelte');

View File

@@ -1,10 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+layout.server'; import { load } from './+layout.server';
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { createApiClient } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';

View File

@@ -1,6 +1,6 @@
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
@@ -55,9 +55,8 @@ export const actions: Actions = {
}); });
if (!result.response.ok) { if (!result.response.ok) {
return fail(result.response.status, { const code = (result.error as unknown as { code?: string })?.code;
error: getErrorMessage(extractErrorCode(result.error)) return fail(result.response.status, { error: getErrorMessage(code) });
});
} }
return { success: true }; return { success: true };
@@ -70,9 +69,8 @@ export const actions: Actions = {
}); });
if (!result.response.ok) { if (!result.response.ok) {
return fail(result.response.status, { const code = (result.error as unknown as { code?: string })?.code;
error: getErrorMessage(extractErrorCode(result.error)) return fail(result.response.status, { error: getErrorMessage(code) });
});
} }
throw redirect(303, '/admin/users'); throw redirect(303, '/admin/users');

View File

@@ -4,10 +4,7 @@ vi.mock('$env/dynamic/private', () => ({
env: { API_INTERNAL_URL: 'http://localhost:8080' } env: { API_INTERNAL_URL: 'http://localhost:8080' }
})); }));
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { load, actions } from './+page.server'; import { load, actions } from './+page.server';
import { createApiClient } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';

View File

@@ -15,7 +15,7 @@ vi.mock('$app/forms', () => ({
return () => {}; return () => {};
} }
})); }));
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';

View File

@@ -1,10 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+layout.server'; import { load } from './+layout.server';
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { createApiClient } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';

View File

@@ -1,6 +1,6 @@
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
export const load: PageServerLoad = async ({ fetch, locals }) => { export const load: PageServerLoad = async ({ fetch, locals }) => {
@@ -35,9 +35,8 @@ export const actions: Actions = {
}); });
if (!result.response.ok) { if (!result.response.ok) {
return fail(result.response.status, { const code = (result.error as unknown as { code?: string })?.code;
error: getErrorMessage(extractErrorCode(result.error)) return fail(result.response.status, { error: getErrorMessage(code) });
});
} }
throw redirect(303, '/admin/users'); throw redirect(303, '/admin/users');

View File

@@ -11,7 +11,7 @@ vi.mock('$app/forms', () => ({
return { destroy: vi.fn() }; return { destroy: vi.fn() };
} }
})); }));
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';

View File

@@ -2,7 +2,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
const { default: AdminUserNewPage } = await import('./+page.svelte'); const { default: AdminUserNewPage } = await import('./+page.svelte');

View File

@@ -1,6 +1,4 @@
import { fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors';
import type { components, operations } from '$lib/generated/api'; import type { components, operations } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
@@ -67,31 +65,3 @@ export async function load({ fetch, url }) {
loadError loadError
}; };
} }
export const actions = {
'dismiss-notification': async ({ request, fetch }) => {
const data = await request.formData();
const raw = data.get('notificationId');
const notificationId = typeof raw === 'string' ? raw : null;
if (!notificationId) return fail(400, { error: getErrorMessage(undefined) });
const api = createApiClient(fetch);
const result = await api.PATCH('/api/notifications/{id}/read', {
params: { path: { id: notificationId } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };
},
'mark-all-read': async ({ fetch }) => {
const api = createApiClient(fetch);
const result = await api.POST('/api/notifications/read-all');
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
return { success: true };
}
};

View File

@@ -76,6 +76,14 @@ async function onFilterChange(v: FilterValue) {
}); });
} }
async function onMarkRead(n: NotificationItem) {
await notificationStore.markRead(n);
}
async function onMarkAllRead() {
await notificationStore.markAllRead();
}
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter)); const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
const isEmpty = $derived(displayFeed.length === 0); const isEmpty = $derived(displayFeed.length === 0);
@@ -100,11 +108,7 @@ function retry() {
{#if data.loadError === 'activity'} {#if data.loadError === 'activity'}
<ChronikErrorCard onRetry={retry} /> <ChronikErrorCard onRetry={retry} />
{:else} {:else}
<ChronikFuerDichBox <ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
unread={unread}
optimisticMarkRead={notificationStore.optimisticMarkRead}
optimisticMarkAllRead={notificationStore.optimisticMarkAllRead}
/>
<div class="mt-6"> <div class="mt-6">
<ChronikFilterPills value={data.filter} onChange={onFilterChange} /> <ChronikFilterPills value={data.filter} onChange={onFilterChange} />

View File

@@ -1,15 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { load, actions } from './+page.server'; import { load } from './+page.server';
const mockApi = { const mockApi = {
GET: vi.fn(), GET: vi.fn()
PATCH: vi.fn(),
POST: vi.fn()
}; };
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({
createApiClient: () => mockApi, createApiClient: () => mockApi
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
})); }));
function buildUrl(search = ''): URL { function buildUrl(search = ''): URL {
@@ -176,84 +173,3 @@ describe('aktivitaeten/load — kinds param per filter', () => {
expect(call[1].params.query.kinds).toHaveLength(2); expect(call[1].params.query.kinds).toHaveLength(2);
}); });
}); });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function makeActionEvent(formData: FormData): any {
return {
request: new Request('http://localhost/aktivitaeten', { method: 'POST', body: formData }),
fetch
};
}
describe('aktivitaeten/actions — dismiss-notification', () => {
it('returns fail(400, { error }) and does NOT call PATCH when notificationId is missing', async () => {
const result = await actions['dismiss-notification'](makeActionEvent(new FormData()));
expect(result).toMatchObject({ status: 400 });
expect(mockApi.PATCH).not.toHaveBeenCalled();
});
it('calls PATCH /api/notifications/{id}/read with the form-supplied notificationId', async () => {
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
const fd = new FormData();
fd.set('notificationId', 'n-abc');
await actions['dismiss-notification'](makeActionEvent(fd));
expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', {
params: { path: { id: 'n-abc' } }
});
});
it('returns { success: true } when the API responds ok', async () => {
mockApi.PATCH.mockResolvedValue({ response: { ok: true }, data: {} });
const fd = new FormData();
fd.set('notificationId', 'n-abc');
const result = await actions['dismiss-notification'](makeActionEvent(fd));
expect(result).toEqual({ success: true });
});
it('returns fail(status, { error }) when the API responds non-ok', async () => {
mockApi.PATCH.mockResolvedValue({
response: { ok: false, status: 403 },
error: { code: 'NOTIFICATION_NOT_FOUND' }
});
const fd = new FormData();
fd.set('notificationId', 'n-abc');
const result = await actions['dismiss-notification'](makeActionEvent(fd));
expect(result).toMatchObject({ status: 403 });
});
});
describe('aktivitaeten/actions — mark-all-read', () => {
it('calls POST /api/notifications/read-all', async () => {
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all');
});
it('returns { success: true } when the API responds ok', async () => {
mockApi.POST.mockResolvedValue({ response: { ok: true }, data: null });
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(result).toEqual({ success: true });
});
it('returns fail(status, { error }) when the API responds non-ok', async () => {
mockApi.POST.mockResolvedValue({
response: { ok: false, status: 500 },
error: { code: 'INTERNAL_ERROR' }
});
const result = await actions['mark-all-read'](makeActionEvent(new FormData()));
expect(result).toMatchObject({ status: 500 });
});
});

View File

@@ -14,7 +14,19 @@ vi.mock('$app/state', () => ({
} }
})); }));
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
vi.mock('$lib/notification/notifications.svelte', () => ({ vi.mock('$lib/notification/notifications.svelte', () => ({
notificationStore: { notificationStore: {

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { createApiClient, extractErrorCode } from '$lib/shared/api.server'; import { createApiClient } from '$lib/shared/api.server';
import { getErrorMessage } from '$lib/shared/errors'; import { getErrorMessage } from '$lib/shared/errors';
export async function load({ url, fetch, locals }) { export async function load({ url, fetch, locals }) {
@@ -39,7 +39,8 @@ export async function load({ url, fetch, locals }) {
}) })
.then((result) => { .then((result) => {
if (!result.response.ok) { if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
} }
documents = result.data ?? []; documents = result.data ?? [];
}) })
@@ -48,7 +49,8 @@ export async function load({ url, fetch, locals }) {
requests.push( requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then((result) => { api.GET('/api/persons/{id}', { params: { path: { id: senderId } } }).then((result) => {
if (!result.response.ok) { if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
} }
const p = result.data as { displayName: string } | undefined; const p = result.data as { displayName: string } | undefined;
if (p) senderName = p.displayName; if (p) senderName = p.displayName;
@@ -60,7 +62,8 @@ export async function load({ url, fetch, locals }) {
requests.push( requests.push(
api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then((result) => { api.GET('/api/persons/{id}', { params: { path: { id: receiverId } } }).then((result) => {
if (!result.response.ok) { if (!result.response.ok) {
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
} }
const p = result.data as { displayName: string } | undefined; const p = result.data as { displayName: string } | undefined;
if (p) receiverName = p.displayName; if (p) receiverName = p.displayName;

View File

@@ -3,7 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import CorrespondenzHero from './CorrespondenzHero.svelte'; import CorrespondenzHero from './CorrespondenzHero.svelte';
vi.mock('$app/navigation'); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(cleanup); afterEach(cleanup);

View File

@@ -1,10 +1,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
import { load } from './+page.server'; import { load } from './+page.server';
vi.mock('$lib/shared/api.server', () => ({ vi.mock('$lib/shared/api.server', () => ({ createApiClient: vi.fn() }));
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
vi.mock('$lib/shared/errors', () => ({ vi.mock('$lib/shared/errors', () => ({
getErrorMessage: (code: string) => code ?? 'Unknown error' getErrorMessage: (code: string) => code ?? 'Unknown error'
})); }));

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