Compare commits

..

2 Commits

Author SHA1 Message Date
Marcel
9fcd0553bd test(mocks): switch $app/navigation factory mocks to shared __mocks__ stub
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m51s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m43s
CI / fail2ban Regex (pull_request) Failing after 42s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
Migrates 36 of the remaining 37 vi.mock('$app/navigation', factory) call
sites in frontend/src/**/*.svelte.{spec,test}.ts to
vi.mock('$app/navigation') (no factory), so they all resolve to the
shared src/__mocks__/$app/navigation.ts stub introduced in commit
aea37250.

Special handling:
- DropZone.svelte.spec.ts: removed vi.hoisted invalidateAllMock; test
  body now imports invalidateAll from $app/navigation and uses
  vi.mocked(invalidateAll) for assertions.
- documents/bulk-edit/page.svelte.test.ts: removed the module-scope
  const gotoSpy = vi.fn() that was injected into the factory; test
  body now imports goto from $app/navigation and uses
  vi.mocked(goto) for assertions and mockClear().

Deferred: src/lib/shared/hooks/useUnsavedWarning.svelte.test.ts uses a
non-trivial beforeNavigate wrapper that captures the callback into
module-scope state (registeredBeforeNavigate) so tests can simulate
navigation events. That hybrid factory cannot be replaced with the
plain-vi.fn() shared stub without redesigning the test; it is left in
place and will be addressed separately.

Per ADR-012: eliminating factory bodies removes 36 birpc teardown-race
surface points. The shared mock keeps every nav export as a vi.fn().

Refs #560.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:42:52 +02:00
Marcel
aea37250f4 test(mocks): wire DocumentRow spec to shared __mocks__/$app/navigation
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m33s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m35s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s
Smallest possible first step of issue #560. Adds a shared
__mocks__/$app/navigation.ts exporting every nav function as vi.fn(),
and switches one consumer (DocumentRow.svelte.spec.ts) from a per-spec
factory to vi.mock('\$app/navigation') (no factory) to verify Vitest's
__mocks__/ redirect resolves for SvelteKit virtual modules in
browser-mode tests.

CI is the gate: if the migrated spec passes alongside the 35 unchanged
factory call sites, the same redirect pattern is applied across the
remaining $app/navigation, $app/state, $app/forms and paraglide
factories.

Per ADR-012, eliminating factory mocks shrinks the birpc teardown-race
surface; this commit is the first concrete cut.

Refs #560.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:19:45 +02:00
104 changed files with 460 additions and 3417 deletions

View File

@@ -39,12 +39,6 @@ PORT_PROMETHEUS=9090
# Grafana admin password — change this before exposing Grafana beyond localhost
GRAFANA_ADMIN_PASSWORD=changeme
# Password for the read-only grafana_reader PostgreSQL role used by the PO
# Overview dashboard. Consumed by Flyway V68 (to set the role's password) and
# by Grafana's PostgreSQL datasource (to connect). REQUIRED in production —
# generate with: openssl rand -hex 32
GRAFANA_DB_PASSWORD=changeme-generate-with-openssl-rand-hex-32
# GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
GLITCHTIP_DOMAIN=http://localhost:3002

View File

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

View File

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

View File

@@ -197,7 +197,6 @@ frontend/src/routes/
├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum)
├── themen/ Topics directory — browsable tag index
├── enrich/ Enrichment workflow — [id], done
├── admin/ User, group, tag, OCR, system management
├── hilfe/transkription/ Transcription help page

View File

@@ -5,10 +5,8 @@ import lombok.extern.slf4j.Slf4j;
import org.flywaydb.core.Flyway;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import javax.sql.DataSource;
import java.util.Map;
@Configuration
@RequiredArgsConstructor
@@ -16,7 +14,6 @@ import java.util.Map;
public class FlywayConfig {
private final DataSource dataSource;
private final Environment environment;
@Bean(name = "flyway")
public Flyway flyway() {
@@ -24,7 +21,6 @@ public class FlywayConfig {
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration")
.placeholders(Map.of("grafanaDbPassword", resolveGrafanaDbPassword()))
.baselineOnMigrate(true)
.baselineVersion("4")
.load();
@@ -32,22 +28,4 @@ public class FlywayConfig {
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
return flyway;
}
// Fail-closed: refuse to boot when GRAFANA_DB_PASSWORD is unset. The
// grafana_reader role's password is (re)set on every boot by
// R__grafana_reader_password.sql, so a missing env var means we'd either
// skip the rotation silently or — with a hardcoded fallback — publish a
// well-known credential for a role with SELECT on audit_log, documents,
// and transcription_blocks. Same shape as UserDataInitializer's refusal
// to seed default admin credentials outside dev/test/e2e.
String resolveGrafanaDbPassword() {
String value = environment.getProperty("GRAFANA_DB_PASSWORD");
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"GRAFANA_DB_PASSWORD is required: it is consumed by "
+ "R__grafana_reader_password.sql to (re)set the grafana_reader "
+ "role's password on every boot. Generate with: openssl rand -hex 32");
}
return value;
}
}

View File

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

View File

@@ -1,41 +0,0 @@
package org.raddatz.familienarchiv.document;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public record DocumentListItem(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String title,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String originalFilename,
String thumbnailUrl,
LocalDate documentDate,
Person sender,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<Person> receivers,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<Tag> tags,
String archiveBox,
String archiveFolder,
String location,
String summary,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int completionPercentage,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<ActivityActorDTO> contributors,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
SearchMatchData matchData,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime createdAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime updatedAt
) {}

View File

@@ -0,0 +1,18 @@
package org.raddatz.familienarchiv.document;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.document.Document;
import java.util.List;
public record DocumentSearchItem(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
Document document,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
SearchMatchData matchData,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int completionPercentage,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<ActivityActorDTO> contributors
) {}

View File

@@ -7,7 +7,7 @@ import java.util.List;
public record DocumentSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<DocumentListItem> items,
List<DocumentSearchItem> items,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long totalElements,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@@ -21,16 +21,16 @@ public record DocumentSearchResult(
* Single-page convenience factory used by empty-result shortcuts and by tests that
* don't care about paging. Treats the whole list as page 0 of itself.
*/
public static DocumentSearchResult of(List<DocumentListItem> items) {
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
int size = items.size();
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
}
/**
* Paged factory used by the service when it has a real Pageable + full match count
* (e.g. from Spring's Page&lt;T&gt; or from an in-memory sort-then-slice).
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
*/
public static DocumentSearchResult paged(List<DocumentListItem> slice, Pageable pageable, long totalElements) {
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
int pageSize = pageable.getPageSize();
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);

View File

@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.document.DocumentBatchSummary;
import org.raddatz.familienarchiv.document.DocumentBulkEditDTO;
import org.raddatz.familienarchiv.document.DocumentSearchItem;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
@@ -735,7 +736,7 @@ public class DocumentService {
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
}
private List<DocumentListItem> enrichItems(List<Document> documents, String text) {
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
List<Document> colorResolved = resolveDocumentTagColors(documents);
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
@@ -743,7 +744,7 @@ public class DocumentService {
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
return colorResolved.stream().map(doc -> toListItem(
return colorResolved.stream().map(doc -> new DocumentSearchItem(
doc,
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
completionByDoc.getOrDefault(doc.getId(), 0),
@@ -751,28 +752,6 @@ public class DocumentService {
)).toList();
}
private DocumentListItem toListItem(Document doc, SearchMatchData match, int completionPct, List<ActivityActorDTO> contributors) {
return new DocumentListItem(
doc.getId(),
doc.getTitle(),
doc.getOriginalFilename(),
doc.getThumbnailUrl(),
doc.getDocumentDate(),
doc.getSender(),
List.copyOf(doc.getReceivers()),
List.copyOf(doc.getTags()),
doc.getArchiveBox(),
doc.getArchiveFolder(),
doc.getLocation(),
doc.getSummary(),
completionPct,
contributors,
match,
doc.getCreatedAt(),
doc.getUpdatedAt()
);
}
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
return transcriptionBlockQueryService.getCompletionStats(docIds);
}

View File

@@ -1,14 +0,0 @@
-- Repeatable migration: sets the grafana_reader role's password from the
-- ${grafanaDbPassword} placeholder (resolved by FlywayConfig from the
-- GRAFANA_DB_PASSWORD environment variable). Flyway computes the checksum on
-- the resolved migration content, so any change to GRAFANA_DB_PASSWORD changes
-- the checksum and re-applies this migration on the next boot. That makes
-- password rotation a "change env var + restart" operation — no manual psql.
--
-- V68 created the role itself (without a usable password). This file owns the
-- password lifecycle; nothing else writes it.
DO $$
BEGIN
EXECUTE format('ALTER ROLE grafana_reader WITH PASSWORD %L', '${grafanaDbPassword}');
END
$$;

View File

@@ -1,17 +0,0 @@
-- Read-only role used by the Grafana PostgreSQL datasource for the PO Overview
-- dashboard (issue #651). The role is created here without a usable password
-- (LOGIN-capable but no password set); R__grafana_reader_password.sql sets the
-- password from GRAFANA_DB_PASSWORD on every boot, so rotation is just "bump
-- the env var and restart the backend" — see docs/adr/024-* and the rotation
-- runbook in docs/DEPLOYMENT.md.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'grafana_reader') THEN
CREATE ROLE grafana_reader WITH LOGIN;
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

@@ -1,37 +0,0 @@
package org.raddatz.familienarchiv.config;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FlywayConfigTest {
@Test
void resolveGrafanaDbPassword_throws_when_env_unset() {
FlywayConfig config = new FlywayConfig(null, new MockEnvironment());
assertThatThrownBy(config::resolveGrafanaDbPassword)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("GRAFANA_DB_PASSWORD is required");
}
@Test
void resolveGrafanaDbPassword_throws_when_env_blank() {
MockEnvironment env = new MockEnvironment().withProperty("GRAFANA_DB_PASSWORD", " ");
FlywayConfig config = new FlywayConfig(null, env);
assertThatThrownBy(config::resolveGrafanaDbPassword)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("GRAFANA_DB_PASSWORD is required");
}
@Test
void resolveGrafanaDbPassword_returns_value_when_env_set() {
MockEnvironment env = new MockEnvironment().withProperty("GRAFANA_DB_PASSWORD", "abc");
FlywayConfig config = new FlywayConfig(null, env);
assertThat(config.resolveGrafanaDbPassword()).isEqualTo("abc");
}
}

View File

@@ -1,89 +0,0 @@
package org.raddatz.familienarchiv.config;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
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;
// GRAFANA_DB_PASSWORD is supplied via the global test default in
// src/test/resources/application.properties — FlywayConfig fails closed
// when it is unset, so all tests that load the migration path need it.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class GrafanaReaderRoleIntegrationTest {
@Autowired JdbcTemplate jdbc;
// --- positive grants (SELECT on the three explicitly granted tables) ---
@Test
void grafana_reader_has_select_on_audit_log() {
assertThat(hasPrivilege("audit_log", "SELECT")).isTrue();
}
@Test
void grafana_reader_has_select_on_documents() {
assertThat(hasPrivilege("documents", "SELECT")).isTrue();
}
@Test
void grafana_reader_has_select_on_transcription_blocks() {
assertThat(hasPrivilege("transcription_blocks", "SELECT")).isTrue();
}
// --- write-deny on the granted tables: SELECT-only means SELECT-only.
// A future migration that GRANTs INSERT/UPDATE/DELETE on any of these
// would fail these tests, even though the original positive grants still
// pass. Locks the boundary in both directions.
@Test
void grafana_reader_has_no_INSERT_on_documents() {
assertThat(hasPrivilege("documents", "INSERT")).isFalse();
}
@Test
void grafana_reader_has_no_UPDATE_on_audit_log() {
assertThat(hasPrivilege("audit_log", "UPDATE")).isFalse();
}
@Test
void grafana_reader_has_no_DELETE_on_transcription_blocks() {
assertThat(hasPrivilege("transcription_blocks", "DELETE")).isFalse();
}
// --- negative grants: PII / sensitive tables MUST NOT be readable.
// The parameterized form catches the "someone widened the grant to
// ALL TABLES IN SCHEMA public" footgun — three specific positive grants
// would still pass while this sweep turns red.
@ParameterizedTest
@ValueSource(strings = {
"app_users",
"user_groups",
"persons",
"notifications",
"document_comments",
"document_annotations",
"geschichten"
})
void grafana_reader_has_no_SELECT_on_protected_table(String table) {
assertThat(hasPrivilege(table, "SELECT")).isFalse();
}
private boolean hasPrivilege(String table, String privilege) {
Boolean result = jdbc.queryForObject(
"SELECT has_table_privilege('grafana_reader', ?, ?)",
Boolean.class,
table,
privilege);
return Boolean.TRUE.equals(result);
}
}

View File

@@ -27,6 +27,7 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.raddatz.familienarchiv.document.DocumentSearchItem;
import org.raddatz.familienarchiv.document.SearchMatchData;
import java.time.LocalDateTime;
@@ -129,14 +130,16 @@ class DocumentControllerTest {
@WithMockUser
void search_responseBodyItemsContainMatchData() throws Exception {
UUID docId = UUID.randomUUID();
Document doc = Document.builder()
.id(docId)
.title("Brief an Anna")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.build();
var matchData = new SearchMatchData(
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, null, null, null,
0, List.of(), matchData,
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)))));
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
.andExpect(status().isOk())
@@ -145,28 +148,6 @@ class DocumentControllerTest {
.value("Er schrieb einen langen Brief"));
}
@Test
@WithMockUser
void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
UUID docId = UUID.randomUUID();
var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, null, null, null,
0, List.of(), matchData,
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)))));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk())
// flat id field present at top of item (not nested under $.items[0].document.id)
.andExpect(jsonPath("$.items[0].id").value(docId.toString()))
// sensitive storage fields must never appear in list response
.andExpect(jsonPath("$.items[0].transcription").doesNotExist())
.andExpect(jsonPath("$.items[0].filePath").doesNotExist())
.andExpect(jsonPath("$.items[0].fileHash").doesNotExist());
}
// ─── /api/documents/search pagination ─────────────────────────────────────
@Test

View File

@@ -127,7 +127,7 @@ class DocumentLazyLoadingTest {
PageRequest.of(0, 20));
assertThat(result.totalElements()).isGreaterThan(0);
assertThatCode(() ->
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
result.items().forEach(i -> i.document().getSender().getLastName()))
.doesNotThrowAnyException();
}

View File

@@ -1,98 +0,0 @@
package org.raddatz.familienarchiv.document;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.ocr.TrainingLabel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.HashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
* AC #2: Document with trainingLabels does not cause LazyInitializationException in search.
* AC #3: Detail API still returns trainingLabels after the Document.list graph change.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class DocumentListItemIntegrationTest {
@MockitoBean
S3Client s3Client;
@MockitoBean
AuditLogQueryService auditLogQueryService;
@Autowired
DocumentRepository documentRepository;
@Autowired
DocumentService documentService;
@AfterEach
void cleanup() {
documentRepository.deleteAll();
}
@Test
void search_doesNotThrow_whenDocumentHasTrainingLabels() {
documentRepository.save(Document.builder()
.title("Kurrent Brief")
.originalFilename("kurrent.pdf")
.status(DocumentStatus.UPLOADED)
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
.build());
assertThatCode(() -> documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(0, 50)))
.doesNotThrowAnyException();
}
@Test
void search_returns_list_item_without_sensitive_fields_when_document_has_training_labels() {
documentRepository.save(Document.builder()
.title("Kurrent Brief")
.originalFilename("kurrent2.pdf")
.status(DocumentStatus.UPLOADED)
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
.build());
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(0, 50));
assertThat(result.totalElements()).isGreaterThan(0);
DocumentListItem item = result.items().get(0);
assertThat(item.id()).isNotNull();
assertThat(item.title()).isEqualTo("Kurrent Brief");
}
@Test
void detail_stillReturnsTrainingLabels() {
Document saved = documentRepository.save(Document.builder()
.title("Detail Test")
.originalFilename("detail_test.pdf")
.status(DocumentStatus.UPLOADED)
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
.build());
// Document.full entity graph (used by getDocumentById) must still load trainingLabels
Document loaded = documentService.getDocumentById(saved.getId());
assertThat(loaded.getTrainingLabels()).containsExactly(TrainingLabel.KURRENT_RECOGNITION);
}
}

View File

@@ -125,10 +125,10 @@ class DocumentSearchPagedIntegrationTest {
// No document id should appear on both pages — slicing must be exclusive.
var idsOnPage0 = page0.items().stream()
.map(item -> item.id())
.map(item -> item.document().getId())
.toList();
var idsOnPage1 = page1.items().stream()
.map(item -> item.id())
.map(item -> item.document().getId())
.toList();
for (UUID id : idsOnPage0) {
assertThat(idsOnPage1).doesNotContain(id);

View File

@@ -3,9 +3,10 @@ package org.raddatz.familienarchiv.document;
import io.swagger.v3.oas.annotations.media.Schema;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.springframework.data.domain.PageRequest;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@@ -13,12 +14,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class DocumentSearchResultTest {
private DocumentListItem item(UUID docId) {
return new DocumentListItem(
docId, "Test", "test.pdf", null, null, null,
List.of(), List.of(), null, null, null, null,
0, List.of(), SearchMatchData.empty(),
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0));
private DocumentSearchItem item(UUID docId) {
Document doc = Document.builder()
.id(docId)
.title("Test")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.build();
return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of());
}
@Test
@@ -42,7 +45,7 @@ class DocumentSearchResultTest {
@Test
void paged_factory_populates_paging_fields_from_pageable_and_total() {
List<DocumentListItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
@@ -65,11 +68,9 @@ class DocumentSearchResultTest {
void of_exposes_items_with_completion_and_contributors() {
UUID id = UUID.randomUUID();
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
DocumentListItem item = new DocumentListItem(
id, "T", "t.pdf", null, null, null,
List.of(), List.of(), null, null, null, null,
75, List.of(actor), SearchMatchData.empty(),
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0));
Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf")
.status(DocumentStatus.UPLOADED).build();
DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor));
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));

View File

@@ -70,7 +70,7 @@ class DocumentServiceSortTest {
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
assertThat(result.items()).hasSize(2);
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer first
}
// ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
@@ -104,7 +104,7 @@ class DocumentServiceSortTest {
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
assertThat(result.items().get(0).id()).isEqualTo(id1);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
@Test
@@ -121,7 +121,7 @@ class DocumentServiceSortTest {
DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
assertThat(result.items().get(0).id()).isEqualTo(id1);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
}
// ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
@@ -156,7 +156,7 @@ class DocumentServiceSortTest {
DocumentSort.RELEVANCE, null, null, PAGE);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).id()).isEqualTo(uuidId);
assertThat(result.items().get(0).document().getId()).isEqualTo(uuidId);
}
// ─── RELEVANCE sort — text + active filter ────────────────────────────────

View File

@@ -11,7 +11,7 @@ import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.annotation.AnnotationService;
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockQueryService;
import org.raddatz.familienarchiv.document.DocumentListItem;
import org.raddatz.familienarchiv.document.DocumentSearchItem;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
@@ -1444,7 +1444,7 @@ class DocumentServiceTest {
assertThat(result.totalPages()).isEqualTo(3);
assertThat(result.items()).hasSize(50);
// Page 1 (offset 50) under ascending sender sort should start at L050
assertThat(result.items().get(0).sender().getLastName()).isEqualTo("L050");
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
}
@Test
@@ -1565,7 +1565,7 @@ class DocumentServiceTest {
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
assertThat(result.items()).hasSize(2);
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
}
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
@@ -1584,7 +1584,7 @@ class DocumentServiceTest {
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
assertThat(result.items()).extracting(DocumentListItem::title)
assertThat(result.items()).extracting(item -> item.document().getTitle())
.containsExactly("Has Receiver", "No Receivers");
}
@@ -1607,7 +1607,7 @@ class DocumentServiceTest {
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
assertThat(result.items()).extracting(DocumentListItem::title)
assertThat(result.items()).extracting(item -> item.document().getTitle())
.containsExactly("smith doc", "Null lastname doc");
}

View File

@@ -1,8 +1,2 @@
logging.level.root=WARN
logging.level.org.raddatz=INFO
# Default test value so FlywayConfig's fail-closed check passes without each
# test having to set GRAFANA_DB_PASSWORD explicitly. The actual value is
# irrelevant in tests — Flyway only uses it to set the grafana_reader role's
# password, which no test connects with.
GRAFANA_DB_PASSWORD=test-grafana-reader-password

View File

@@ -147,9 +147,6 @@ services:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_SERVER_ROOT_URL: ${GF_SERVER_ROOT_URL:-http://localhost:3003}
# Read-only password for the grafana_reader PostgreSQL role; interpolated
# into the provisioned PostgreSQL datasource (see datasources.yml).
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
volumes:
- grafana_data:/var/lib/grafana
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
@@ -168,7 +165,6 @@ services:
condition: service_healthy
networks:
- obs-net
- archiv-net # PO Overview dashboard queries archive-db via the grafana_reader role
# --- Error Tracking: GlitchTip ---

View File

@@ -227,9 +227,6 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
SPRING_DATASOURCE_USERNAME: archiv
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
# Consumed by Flyway V68 via the ${grafanaDbPassword} placeholder to set
# the read-only grafana_reader role's password.
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
# Application uses the bucket-scoped service account, not MinIO root.
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: archiv-app

View File

@@ -163,9 +163,6 @@ services:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
# Consumed by Flyway V68 via the ${grafanaDbPassword} placeholder to set
# the read-only grafana_reader role's password.
GRAFANA_DB_PASSWORD: ${GRAFANA_DB_PASSWORD}
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: ${MINIO_ROOT_USER}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
@@ -201,7 +198,7 @@ services:
networks:
- archiv-net
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/actuator/health | grep -q UP || exit 1"]
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q UP || exit 1"]
interval: 15s
timeout: 5s
retries: 10

View File

@@ -152,7 +152,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `PORT_GRAFANA` | Host port for the Grafana UI (bound to `127.0.0.1` only) | `3003` | — | — |
| `POSTGRES_HOST` | PostgreSQL hostname for GlitchTip's db-init job and workers. Override when only the staging stack is running and `archive-db` is not resolvable by that name. | `archive-db` | — | — |
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` user password | `changeme` | YES (prod) | YES |
| `GRAFANA_DB_PASSWORD` | Password for the read-only `grafana_reader` PostgreSQL role used by the PO Overview dashboard (issue #651). Consumed by Flyway V68 and the Grafana PostgreSQL datasource. Generate with `openssl rand -hex 32`. | — | YES (prod) | YES |
| `PORT_GLITCHTIP` | Host port for the GlitchTip UI (bound to `127.0.0.1` only) | `3002` | — | — |
| `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (used in email links and CORS) | `http://localhost:3002` | YES (prod) | — |
| `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` | — | YES | YES |
@@ -257,7 +256,6 @@ git.raddatz.cloud A <server IP>
| `MAIL_USERNAME` | release.yml | SMTP user |
| `MAIL_PASSWORD` | release.yml | SMTP password |
| `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong password |
| `GRAFANA_DB_PASSWORD` | both | Read-only `grafana_reader` role password — `openssl rand -hex 32` |
| `GLITCHTIP_SECRET_KEY` | both | Django secret key — `openssl rand -hex 32` |
| `SENTRY_DSN` | both | GlitchTip project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
| `VITE_SENTRY_DSN` | both | GlitchTip frontend project DSN — set after first-run (§4); leave empty to keep Sentry disabled |
@@ -359,7 +357,6 @@ Both files are passed explicitly via `--env-file` to the compose command, so the
| Gitea secret | Notes |
|---|---|
| `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release |
| `GRAFANA_DB_PASSWORD` | `openssl rand -hex 32`; shared by nightly and release — read-only DB role for the PO Overview dashboard |
| `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release |
| `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container |
@@ -430,31 +427,6 @@ docker exec obs-loki wget -qO- \
Prometheus port `9090` and Grafana port `3003` (default; configurable via `PORT_GRAFANA`) are bound to `127.0.0.1` on the host. No other observability ports are host-bound.
##### Rotate the `grafana_reader` DB password
The PO Overview dashboard reads `audit_log`, `documents`, and `transcription_blocks` through the SELECT-only `grafana_reader` PostgreSQL role (issue #651, ADR-024). The role's password is owned by `R__grafana_reader_password.sql` — a Flyway *repeatable* migration that re-runs whenever the resolved `${grafanaDbPassword}` placeholder changes. That makes rotation a two-restart operation, no manual `psql` required.
```bash
# 1. Generate a new value
openssl rand -hex 32
# 2. Update both sides:
# - Gitea secret GRAFANA_DB_PASSWORD (nightly + release workflows pick it up)
# - Local .env on the server / dev machine
# 3. Restart the backend. Flyway sees that R__'s resolved checksum changed and
# re-applies it, issuing ALTER ROLE grafana_reader WITH PASSWORD '<new>'.
docker compose restart backend
# 4. Restart obs-grafana so the provisioned datasource picks up the new env value.
docker compose -f docker-compose.observability.yml restart obs-grafana
# 5. Verify the dashboard loads — PO Overview's Postgres panels should populate
# instead of "Data source error".
```
If `GRAFANA_DB_PASSWORD` is unset, the backend **refuses to start** (`IllegalStateException`). That is deliberate — see `FlywayConfig.resolveGrafanaDbPassword()` and the rationale in ADR-024.
#### GlitchTip
| Item | Value |

View File

@@ -80,14 +80,6 @@ _See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._
**Sütterlin** — A specific standardized style of Kurrent taught in German schools from 1915 to 1941.
**Illegible word** — a word whose recognition confidence falls below the configured threshold; replaced with the literal token `[unleserlich]` in the rendered block text and counted in the `ocr_illegible_words_total` Prometheus counter.
**Models-ready gauge** — the `ocr_models_ready` Prometheus gauge, flipped from `0` to `1` once the FastAPI lifespan startup has finished loading the Kraken model and the spell-checker. Used both for the `/health` endpoint and as the supervised signal for the `ocr_models_ready < 1 for 2m` alert.
**Recognition model accuracy** — the accuracy reported by `ketos train` for the recognition (text-line) model, exposed as `ocr_model_accuracy{kind="recognition"}`. Sourced from `_parse_best_checkpoint` on the highest-scoring checkpoint after training.
**Segmentation model accuracy** — the accuracy reported by `ketos segtrain` for the baseline layout analysis (`blla`) model, exposed as `ocr_model_accuracy{kind="segmentation"}`. Distinct from recognition accuracy because the two models are trained and improved independently.
---
## Other Domain Terms

View File

@@ -118,14 +118,11 @@ To find a trace for a specific request in staging/production, either increase th
## Metrics (Prometheus → Grafana)
Prometheus scrapes two targets every 15 s:
Prometheus scrapes the backend management endpoint every 15 s:
```
Target: backend:8081/actuator/prometheus
Labels: job="spring-boot", application="Familienarchiv"
Target: ocr:8000/metrics
Labels: job="ocr-service"
```
All Spring Boot metrics carry the `application="Familienarchiv"` tag, which is how the Grafana Spring Boot Observability dashboard (ID 17175) filters to this service.
@@ -149,70 +146,6 @@ jvm_memory_used_bytes{area="heap", application="Familienarchiv"}
hikaricp_connections_active
```
### OCR-service custom metrics
Exposed at `ocr:8000/metrics` by `prometheus-fastapi-instrumentator`. The
`http_*` metrics describe the FastAPI request layer; the `ocr_*` series are
domain-specific. **Never label these with PII or document content** — labels
have unbounded cardinality risk and are visible to anyone with Grafana access.
| Metric | Type | Labels | Unit | What it tracks |
|---|---|---|---|---|
| `ocr_jobs_total` | Counter | `engine` (`surya`/`kraken`), `script_type` | jobs | OCR jobs that started after a successful PDF download |
| `ocr_pages_total` | Counter | `engine` | pages | Successfully OCR'd pages in the streaming generator |
| `ocr_skipped_pages_total` | Counter | — | pages | Pages skipped because the engine raised on them |
| `ocr_words_total` | Counter | — | words | Recognized words summed across every block |
| `ocr_illegible_words_total` | Counter | — | words | Words below the confidence threshold (rendered as `[unleserlich]`) |
| `ocr_processing_seconds` | Histogram | `engine` | seconds | Per-page (stream) or per-document (`/ocr`) engine time, excluding preprocessing |
| `ocr_training_runs_total` | Counter | `kind` (`recognition`/`segmentation`), `outcome` (`success`/`error`) | runs | Completed training runs |
| `ocr_model_accuracy` | Gauge | `kind` | ratio (01) | Latest accuracy reported by a successful training run |
| `ocr_models_ready` | Gauge | — | 0\|1 | 1 once the lifespan startup has finished loading models |
Canonical example queries (the same ones referenced in issue #652):
```promql
# OCR throughput by engine
sum by (engine) (rate(ocr_pages_total[5m]))
# Share of words rendered as [unleserlich]
sum(rate(ocr_illegible_words_total[5m]))
/ sum(rate(ocr_words_total[5m]))
# p95 page processing time per engine
histogram_quantile(0.95, sum by (engine, le) (
rate(ocr_processing_seconds_bucket[5m])
))
# Training error rate
sum(rate(ocr_training_runs_total{outcome="error"}[1h]))
/ sum(rate(ocr_training_runs_total[1h]))
# Latest recognition vs segmentation accuracy
ocr_model_accuracy
```
### Internal-only endpoints
`/metrics` is exposed by the OCR service over plain HTTP without
authentication. The container is reachable only on the internal Docker
network — Caddy never proxies to it directly. If the service is ever
exposed (e.g. a `ports:` mapping is added), block the endpoint at the
reverse proxy:
```caddy
ocr.example.com {
@internal_only path /metrics /health
respond @internal_only 404
reverse_proxy ocr:8000
}
```
The `MetricsPathFilter` in `ocr-service/main.py` suppresses uvicorn's
**stdout** access log lines for `/metrics` and `/health` so the container
console stays focused on real OCR traffic. Promtail/Loki still receive
access lines from any other source. Treat the filter as console
noise-control, not an audit-suppression mechanism.
## Errors (GlitchTip)
GlitchTip receives errors from both the backend (via Sentry Java SDK) and the frontend (via Sentry JavaScript SDK). It groups events by fingerprint, tracks first/last seen times, and links to the release that introduced the error.

View File

@@ -1,94 +0,0 @@
# ADR-023: Prometheus Instrumentator and Metrics Registry Injection
## Status
Accepted
## Context
Until issue #652 the OCR service exposed no `/metrics` endpoint. The
observability stack already scrapes the Spring Boot backend's actuator
endpoint, but it had nothing to scrape on the Python side. Without HTTP-
and domain-level metrics from `ocr-service` we cannot answer questions
like "what is the share of words rendered as `[unleserlich]`" or
"is the training error rate above its budget" from Grafana.
Two implementation requirements influenced the design:
1. **Counter / gauge isolation in tests.** `prometheus_client` collectors
are module-level singletons keyed by name on the global `REGISTRY`.
Re-importing or naively re-instantiating them raises a duplicated-
collector error and cross-test state leaks (a `.inc()` in test A is
still readable by test B). A test harness needs a way to swap the
active container for a fresh per-test instance.
2. **Minimal blast radius on the request path.** We did not want to
hand-instrument every endpoint with FastAPI middleware. The
`prometheus-fastapi-instrumentator` library already provides
`http_requests_total`, `http_request_duration_seconds`, and the
`/metrics` exposition route, all idiomatic Prometheus names.
## Decision
- Add `prometheus-fastapi-instrumentator==7.0.0` and pin its transitive
dependency `prometheus-client==0.25.0` explicitly in
`ocr-service/requirements.txt`.
- Mount the instrumentator once at module load:
`Instrumentator(excluded_handlers=["/health", "/metrics"]).instrument(app).expose(app)`.
This adds `/metrics` and an HTTP-level dashboard surface without
changing any endpoint code.
- Define every domain metric (`ocr_jobs_total`, `ocr_pages_total`,
`ocr_processing_seconds`, …) inside a `build_metrics(registry)`
factory in `ocr-service/metrics.py` that returns a frozen `OcrMetrics`
dataclass. Production code binds the container to the default
`REGISTRY` once: `metrics: OcrMetrics = build_metrics(REGISTRY)`.
- Tests use a `fresh_metrics` fixture that builds a new
`CollectorRegistry()` per test and monkeypatches `main.metrics` with
a container bound to it. The endpoint code keeps reading
`metrics.<name>` without knowing whether it is talking to the global
registry or a per-test one.
## Consequences
**Positive**
- One reusable factory captures the metric definitions; future metrics
go in one place.
- Tests run with full counter isolation. Cross-test state leakage is
impossible because each test sees its own dataclass instance.
- The instrumentator gives us `http_*` metrics for free, including a
Grafana-ready histogram that pairs with the Spring Boot one.
**Negative**
- One extra level of indirection: any test that asserts on metric
values must remember to monkeypatch `main.metrics`, not the registry
directly. Rebinding through the registry is harmless but useless —
the dataclass holds references to the original collectors.
- `prometheus-client` is now pinned. Upgrading it requires an explicit
bump and re-checking the instrumentator's compatibility range.
- `/metrics` is exposed unauthenticated and relies on the Docker
internal network for confidentiality. See
[docs/OBSERVABILITY.md §Internal-only endpoints](../OBSERVABILITY.md)
for the Caddy snippet that must be added if the service ever gets a
host-side port mapping.
## Alternatives considered
- **Hand-roll the `/metrics` endpoint.** Rejected: would have meant
duplicating what `prometheus-fastapi-instrumentator` ships, plus
middleware for the HTTP histograms.
- **Skip the factory; pass `registry` as a function argument
everywhere.** Rejected: clutters every endpoint signature and breaks
the symmetry with the Spring Boot side, which also relies on a
process-global Micrometer registry.
- **Use a `pytest` autouse fixture that resets `REGISTRY` between
tests.** Rejected: `prometheus_client` does not expose a clean
"unregister all" hook, and we would be relying on private APIs.
## References
- Issue: [#652](https://git.raddatz.cloud/marcel/familienarchiv/issues/652)
- Library: <https://github.com/trallnag/prometheus-fastapi-instrumentator>
- Code: `ocr-service/metrics.py`, `ocr-service/main.py`,
`ocr-service/test_metrics.py`

View File

@@ -1,123 +0,0 @@
# ADR-024: Grafana reads archive-db via a bridged network and a SELECT-only role
## Status
Accepted
## Context
Issue #651 (the PO Overview Grafana dashboard) needs aggregates over three
tables in the main application database — `audit_log`, `documents`, and
`transcription_blocks` — to answer the operator's four weekly questions: is
everything working, are people using it, is the archive making progress, is
OCR working well.
Until now, `obs-grafana` and the rest of the observability stack lived on
their own Docker network (`obs-net`) and never touched `archiv-net`, where
`archive-db` runs. The two were intentionally isolated: a compromise of any
observability container could not pivot to the application database.
The PO Overview's archive-progress and user-activity panels need rolling
7-day SQL aggregates that cannot be served by Prometheus or Loki. That
forces a connection from `obs-grafana` to `archive-db` for the first time.
Two implementation requirements shaped the design:
1. **Least privilege on the database side.** The Spring Boot application
role (`archiv`) has full read/write on every table. Letting Grafana
connect with that role would mean a Grafana compromise becomes an
application compromise. The dashboard only needs SELECT on three
tables; the role must reflect that and nothing more.
2. **Operational simplicity of secret rotation.** The role's password is
shared between the migration that sets it and the Grafana datasource
that uses it. A first version of this work put the password in a
versioned Flyway migration (V68), which Flyway only applies once —
leaving rotation as an out-of-band `psql ALTER ROLE` step that no
runbook documented. The shape must support rotation without manual
SQL.
## Decision
- Provision a dedicated PostgreSQL role `grafana_reader` with `LOGIN` plus
`GRANT SELECT` on `audit_log`, `documents`, `transcription_blocks` only.
No INSERT/UPDATE/DELETE on any table, no access to any other table —
enforced by the database, locked in by both positive and parameterized
negative tests in `GrafanaReaderRoleIntegrationTest`.
- Split the role's lifecycle across two migrations:
- `V68__add_grafana_reader_role.sql` — versioned, immutable, idempotent.
Creates the role and applies the grants. Runs exactly once per
database, like every other versioned migration.
- `R__grafana_reader_password.sql` — Flyway *repeatable* migration that
issues `ALTER ROLE grafana_reader WITH PASSWORD '${grafanaDbPassword}'`.
Flyway computes the checksum on the resolved content, so any change
to `GRAFANA_DB_PASSWORD` flips the checksum and re-applies the
migration on the next boot. Rotation becomes "bump env var, restart
backend, restart obs-grafana" — see the runbook in
`docs/DEPLOYMENT.md §4 → Rotate the grafana_reader DB password`.
- Resolve the password through Spring's `Environment` rather than a raw
`System.getenv()` call, so tests inject via `application.properties`
and the resolver is unit-testable with `MockEnvironment`. Fail closed
with `IllegalStateException` when the variable is unset — no fallback
string. Same shape as `UserDataInitializer`'s refusal to seed default
admin credentials outside dev/test/e2e.
- Join `obs-grafana` to `archiv-net` in addition to `obs-net`. Only the
Grafana container crosses the boundary; Loki, Tempo, Prometheus,
GlitchTip, and the worker containers remain `obs-net`-only.
## Consequences
**Positive**
- Database-level least privilege: a Grafana compromise gains SELECT on
three tables. Cannot write, cannot read PII tables like `app_users`,
`persons`, `notifications`, `document_comments`, `geschichten`. The
parameterized PII negative sweep in `GrafanaReaderRoleIntegrationTest`
is the regression gate; new sensitive tables get added to that list.
- Rotation is documented, idempotent, and survives operator turnover.
No "the password set on day 1 is the password forever" failure mode.
- Tests pin down both sides of the boundary: positive grants must hold,
write-deny must hold, and the PII negative list must stay empty.
**Negative / trade-offs**
- `obs-net` is no longer fully isolated from `archiv-net`. A Grafana RCE
(e.g. via a future Grafana CVE) gains a TCP path to `archive-db`
contained, but not impossible. The least-privilege role is the
mitigation; we accept that mitigation as sufficient for a single
bridged container.
- The backend must hold `GRAFANA_DB_PASSWORD` in its environment forever,
so Flyway can resolve the placeholder on every boot. A backend RCE
therefore also leaks the Grafana datasource password. Acceptable
because that password's blast radius is itself bounded by the
least-privilege grants on `grafana_reader`.
## Alternatives considered
- **Prometheus PostgreSQL exporter, no direct connection.** Loses ad-hoc
SQL aggregates — the dashboard would need every metric pre-defined as
an exporter query, with a redeploy to add a new one. The PO Overview
is the type of dashboard that grows panels over time; pre-defining
every aggregate is the wrong shape.
- **Read replica or logical-replication slot dedicated to Grafana.**
Real operational cost (extra Postgres instance, replication monitoring,
storage doubled) disproportionate to a weekly PO glance.
- **Versioned migration with `flyway repair` for rotation.** Rejected:
conflates schema lifecycle with credential lifecycle, requires manual
intervention to rotate, and the repair command's semantics are
surprising to operators unfamiliar with Flyway internals.
- **Hardcoded fallback password when env var is unset.** Rejected as a
security blocker: publishes a known credential for a role with read
access to user activity and full letter text. The fail-closed
behavior is the explicit defense.
## References
- Issue #651 — PO Overview Grafana dashboard
- `backend/src/main/resources/db/migration/V68__add_grafana_reader_role.sql`
- `backend/src/main/resources/db/migration/R__grafana_reader_password.sql`
- `backend/src/main/java/org/raddatz/familienarchiv/config/FlywayConfig.java`
- `backend/src/test/java/org/raddatz/familienarchiv/config/GrafanaReaderRoleIntegrationTest.java`
- `infra/observability/grafana/provisioning/datasources/datasources.yml`
- `docker-compose.observability.yml``archiv-net` bridge on `obs-grafana`
- `docs/DEPLOYMENT.md §4` — rotation runbook

View File

@@ -43,12 +43,9 @@ Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
Rel(mc, storage, "Bootstraps bucket + service account on startup", "MinIO Client CLI")
Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API")
Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)")
Rel(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus")
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
Rel(grafana, loki, "Queries logs", "HTTP 3100")
Rel(grafana, tempo, "Queries traces", "HTTP 3200")
Rel(grafana, db, "Read-only dashboard queries via grafana_reader role", "PostgreSQL / archiv-net")
Rel(glitchtip, db, "Stores error events in glitchtip DB", "PostgreSQL / archiv-net")
Rel(obs_glitchtip_worker, obs_redis, "Processes Celery tasks", "Redis / obs-net")

View File

@@ -14,7 +14,6 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
}
@@ -27,7 +26,6 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications"
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")

View File

@@ -4,7 +4,7 @@
# Used by docker-compose.yml (target: development). Source is bind-mounted in
# dev so the COPY . below is effectively replaced at runtime; the layer still
# exists so the image is self-contained for cold starts (e.g. devcontainer).
FROM node:22-alpine3.21 AS development
FROM node:20.19.0-alpine3.21 AS development
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
@@ -14,7 +14,7 @@ CMD ["npm", "run", "dev"]
# ── Build ────────────────────────────────────────────────────────────────────
# Compiles the SvelteKit Node-adapter output to /app/build.
FROM node:22-alpine3.21 AS build
FROM node:20.19.0-alpine3.21 AS build
WORKDIR /app
# VITE_SENTRY_DSN is a build-time variable — Vite bakes it into the bundle.
# Passed via docker-compose build.args; empty string disables the SDK.
@@ -27,7 +27,7 @@ RUN npm run build
# ── Production ───────────────────────────────────────────────────────────────
# Self-contained Node server. `node build` is the adapter-node entrypoint.
FROM node:22-alpine3.21 AS production
FROM node:20.19.0-alpine3.21 AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/build ./build

View File

@@ -1084,10 +1084,5 @@
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt",
"error_page_id_label": "Fehler-ID",
"error_copy_id_label": "ID kopieren",
"error_copied": "Kopiert!",
"themen_widget_title": "Themen",
"themen_alle": "Alle Themen",
"themen_leer": "Noch keine Themen vergeben.",
"themen_weitere": "+ {count} weitere",
"themen_dokumente": "{count} Dokumente"
"error_copied": "Kopiert!"
}

View File

@@ -1084,10 +1084,5 @@
"timeline_dragging_aria_live": "Range {from} to {to} selected",
"error_page_id_label": "Error ID",
"error_copy_id_label": "Copy ID",
"error_copied": "Copied!",
"themen_widget_title": "Topics",
"themen_alle": "All Topics",
"themen_leer": "No topics assigned yet.",
"themen_weitere": "+ {count} more",
"themen_dokumente": "{count} documents"
"error_copied": "Copied!"
}

View File

@@ -1084,10 +1084,5 @@
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado",
"error_page_id_label": "ID de error",
"error_copy_id_label": "Copiar ID",
"error_copied": "¡Copiado!",
"themen_widget_title": "Temas",
"themen_alle": "Todos los temas",
"themen_leer": "Aún no hay temas.",
"themen_weitere": "+ {count} más",
"themen_dokumente": "{count} documentos"
"error_copied": "¡Copiado!"
}

View File

@@ -23,9 +23,9 @@
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@inlang/paraglide-js": "^2.5.0",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.60.1",
"@playwright/test": "^1.58.2",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
@@ -43,7 +43,7 @@
"globals": "^16.5.0",
"openapi-typescript": "^7.8.0",
"patch-package": "^8.0.0",
"playwright": "^1.60.0",
"playwright": "^1.56.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
@@ -52,7 +52,7 @@
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^7.3.3",
"vite": "^7.2.2",
"vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1"

View File

@@ -0,0 +1,20 @@
// 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

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

View File

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

View File

@@ -5,7 +5,7 @@ import { clickOutside } from '$lib/shared/actions/clickOutside';
import { formatDate } from '$lib/shared/utils/date';
type Document = components['schemas']['Document'];
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
interface Props {
selectedDocuments?: Document[];
@@ -45,12 +45,8 @@ function handleInput() {
try {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) {
const body: { items: DocumentListItem[] } = await res.json();
const docs = body.items.map((it) => ({
id: it.id,
title: it.title,
documentDate: it.documentDate
})) as unknown as Document[];
const body: { items: DocumentSearchItem[] } = await res.json();
const docs = body.items.map((it) => it.document);
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
}
} catch {

View File

@@ -10,19 +10,7 @@ const docFactory = (id: string, title: string, date = '1880-01-01') => ({
title,
documentDate: date,
originalFilename: `${title}.pdf`,
receivers: [],
tags: [],
completionPercentage: 0,
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
status: 'UPLOADED' as const,
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
createdAt: '2024-01-01T00:00:00',
@@ -34,7 +22,7 @@ function mockSearchResponse(items: ReturnType<typeof docFactory>[]) {
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items })
json: vi.fn().mockResolvedValue({ items: items.map((document) => ({ document })) })
})
);
}
@@ -103,7 +91,10 @@ describe('DocumentMultiSelect — search and select', () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
items: [docFactory('d1', 'Already attached'), docFactory('d2', 'Not attached')]
items: [
{ document: docFactory('d1', 'Already attached') },
{ document: docFactory('d2', 'Not attached') }
]
})
});
vi.stubGlobal('fetch', fetchMock);

View File

@@ -9,11 +9,11 @@ import ProgressRing from '$lib/shared/primitives/ProgressRing.svelte';
import ContributorStack from '$lib/shared/primitives/ContributorStack.svelte';
import DocumentThumbnail from './DocumentThumbnail.svelte';
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
let { item, canWrite = false }: { item: DocumentListItem; canWrite?: boolean } = $props();
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props();
const doc = $derived(item);
const doc = $derived(item.document);
const titleText = $derived(doc.title || doc.originalFilename);
const titleOffsets = $derived(item.matchData?.titleOffsets ?? []);
const titleSegments = $derived(applyOffsets(titleText, titleOffsets));

View File

@@ -6,7 +6,7 @@ import DocumentRow from './DocumentRow.svelte';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
import type { components } from '$lib/generated/api';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/navigation');
afterEach(() => {
cleanup();
@@ -14,17 +14,24 @@ afterEach(() => {
bulkSelectionStore.clear();
});
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem {
return {
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
documentDate: '2024-03-15',
sender: undefined,
receivers: [],
tags: [],
document: {
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED',
documentDate: '2024-03-15',
sender: null,
receivers: [],
tags: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
metadataComplete: false,
scriptType: 'UNKNOWN'
},
matchData: {
titleOffsets: [],
senderMatched: false,
@@ -48,14 +55,14 @@ describe('DocumentRow title', () => {
});
it('falls back to originalFilename when title is null', async () => {
const item = makeItem({ title: null as unknown as string });
const item = makeItem({ document: { ...makeItem().document, title: null } });
render(DocumentRow, { item });
await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument();
});
it('renders a mark element for highlighted title offsets', async () => {
const item = makeItem({
title: 'Brief an Anna',
document: { ...makeItem().document, title: 'Brief an Anna' },
matchData: {
titleOffsets: [{ start: 0, length: 5 }],
senderMatched: false,
@@ -102,12 +109,9 @@ describe('DocumentRow snippet', () => {
describe('DocumentRow sender', () => {
it('shows sender display name', async () => {
const item = makeItem({
sender: {
id: 's1',
lastName: 'Maria',
displayName: 'Großmutter Maria',
personType: 'PERSON',
familyMember: false
document: {
...makeItem().document,
sender: { id: 's1', displayName: 'Großmutter Maria' }
}
});
render(DocumentRow, { item });
@@ -122,12 +126,9 @@ describe('DocumentRow sender', () => {
it('highlights the sender when senderMatched is true', async () => {
const item = makeItem({
sender: {
id: 's1',
lastName: 'Maria',
displayName: 'Großmutter Maria',
personType: 'PERSON',
familyMember: false
document: {
...makeItem().document,
sender: { id: 's1', displayName: 'Großmutter Maria' }
},
matchData: {
...makeItem().matchData,
@@ -141,15 +142,10 @@ describe('DocumentRow sender', () => {
it('highlights a receiver when matchedReceiverIds includes its id', async () => {
const item = makeItem({
receivers: [
{
id: 'r1',
lastName: 'Karl',
displayName: 'Onkel Karl',
personType: 'PERSON',
familyMember: false
}
],
document: {
...makeItem().document,
receivers: [{ id: 'r1', displayName: 'Onkel Karl' }]
},
matchData: {
...makeItem().matchData,
matchedReceiverIds: ['r1']
@@ -166,7 +162,10 @@ describe('DocumentRow sender', () => {
describe('DocumentRow summary', () => {
it('renders the document summary when present', async () => {
const item = makeItem({
summary: 'Brief von Eugenie über die Heimreise aus dem Süden.'
document: {
...makeItem().document,
summary: 'Brief von Eugenie über die Heimreise aus dem Süden.'
}
});
render(DocumentRow, { item });
await expect
@@ -181,7 +180,7 @@ describe('DocumentRow summary', () => {
it('applies summary search-match highlight via summaryOffsets', async () => {
const item = makeItem({
summary: 'Brief über Menton',
document: { ...makeItem().document, summary: 'Brief über Menton' },
matchData: {
...makeItem().matchData,
summaryOffsets: [{ start: 11, length: 6 }]
@@ -197,19 +196,25 @@ describe('DocumentRow summary', () => {
describe('DocumentRow archive chips', () => {
it('renders the archive box chip when set', async () => {
const item = makeItem({ archiveBox: 'K3' });
const item = makeItem({
document: { ...makeItem().document, archiveBox: 'K3' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('K3')).toBeInTheDocument();
});
it('renders the archive folder chip when set', async () => {
const item = makeItem({ archiveFolder: 'Mappe A' });
const item = makeItem({
document: { ...makeItem().document, archiveFolder: 'Mappe A' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('Mappe A')).toBeInTheDocument();
});
it('renders the location chip when meta_location is set', async () => {
const item = makeItem({ location: 'Berlin' });
const item = makeItem({
document: { ...makeItem().document, location: 'Berlin' }
});
render(DocumentRow, { item });
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
});
@@ -220,7 +225,10 @@ describe('DocumentRow archive chips', () => {
describe('DocumentRow tags', () => {
it('renders tag buttons', async () => {
const item = makeItem({
tags: [{ id: 't1', name: 'Familie' }]
document: {
...makeItem().document,
tags: [{ id: 't1', name: 'Familie', color: null, parentId: null }]
}
});
render(DocumentRow, { item });
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
@@ -228,7 +236,10 @@ describe('DocumentRow tags', () => {
it('navigates to /documents?tag=… on tag click', async () => {
const item = makeItem({
tags: [{ id: 't1', name: 'Urlaub & Reise' }]
document: {
...makeItem().document,
tags: [{ id: 't1', name: 'Urlaub & Reise', color: null, parentId: null }]
}
});
render(DocumentRow, { item });
// Tailwind CSS isn't loaded in the vitest-browser client project, so the
@@ -244,7 +255,10 @@ describe('DocumentRow tags', () => {
it('tag click does not navigate to the document detail page', async () => {
const item = makeItem({
tags: [{ id: 't2', name: 'Familie' }]
document: {
...makeItem().document,
tags: [{ id: 't2', name: 'Familie', color: null, parentId: null }]
}
});
render(DocumentRow, { item });
const before = window.location.href;
@@ -267,7 +281,7 @@ describe('DocumentRow bulk selection checkbox', () => {
});
it('checkbox aria-label includes the document title', async () => {
const item = makeItem({ title: 'Brief an Anna' });
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } });
render(DocumentRow, { item, canWrite: true });
await expect
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
@@ -275,7 +289,7 @@ describe('DocumentRow bulk selection checkbox', () => {
});
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
const item = makeItem({ id: 'doc-42' });
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } });
render(DocumentRow, { item, canWrite: true });
expect(bulkSelectionStore.has('doc-42')).toBe(false);
@@ -286,7 +300,7 @@ describe('DocumentRow bulk selection checkbox', () => {
it('checked state mirrors the store', async () => {
bulkSelectionStore.add('doc-99');
const item = makeItem({ id: 'doc-99' });
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } });
render(DocumentRow, { item, canWrite: true });
await expect.element(page.getByRole('checkbox')).toBeChecked();
});

View File

@@ -2,49 +2,16 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
vi.mock('$app/navigation');
const { default: DocumentRow } = await import('./DocumentRow.svelte');
afterEach(cleanup);
const sender = {
id: 's1',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON' as const,
familyMember: false
};
const receiver = {
id: 'r1',
lastName: 'Meier',
displayName: 'Bert Meier',
personType: 'PERSON' as const,
familyMember: false
};
const sender = { id: 's1', displayName: 'Anna Schmidt' };
const receiver = { id: 'r1', displayName: 'Bert Meier' };
const emptyMatchData = {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
};
const baseItem = (overrides: Record<string, unknown> = {}) => ({
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
title: 'Brief 1923',
originalFilename: 'b.pdf',
@@ -52,16 +19,22 @@ const baseItem = (overrides: Record<string, unknown> = {}) => ({
sender,
receivers: [receiver],
tags: [],
summary: undefined,
archiveBox: undefined,
archiveFolder: undefined,
location: undefined,
matchData: emptyMatchData,
completionPercentage: 0,
contributors: [],
thumbnailUrl: null,
contentType: 'application/pdf',
summary: null,
archiveBox: null,
archiveFolder: null,
location: null,
...overrides
});
const baseItem = (docOverrides: Record<string, unknown> = {}) => ({
document: makeDoc(docOverrides),
matchData: null,
completionPercentage: 0,
contributors: []
});
describe('DocumentRow', () => {
it('renders the title', async () => {
render(DocumentRow, { props: { item: baseItem() } });
@@ -136,9 +109,12 @@ describe('DocumentRow', () => {
it('renders the snippet when matchData provides a transcriptionSnippet', async () => {
render(DocumentRow, {
props: {
item: baseItem({
matchData: { ...emptyMatchData, transcriptionSnippet: 'Hello world snippet' }
})
item: {
document: makeDoc(),
matchData: { transcriptionSnippet: 'Hello world snippet' },
completionPercentage: 50,
contributors: []
}
}
});

View File

@@ -2068,20 +2068,12 @@ export interface components {
};
ImportStatus: {
/** @enum {string} */
state: "IDLE" | "RUNNING" | "DONE" | "FAILED";
statusCode: string;
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
statusCode?: string;
/** Format: int32 */
processed: number;
skippedFiles: components["schemas"]["SkippedFile"][];
processed?: number;
/** Format: date-time */
startedAt?: string;
/** Format: int32 */
skipped?: number;
};
SkippedFile: {
filename: string;
/** @enum {string} */
reason: "INVALID_FILENAME_PATH_TRAVERSAL" | "INVALID_PDF_SIGNATURE" | "FILE_READ_ERROR" | "ALREADY_EXISTS" | "S3_UPLOAD_FAILED";
};
BackfillStatus: {
/** @enum {string} */
@@ -2321,10 +2313,10 @@ export interface components {
/** Format: int32 */
number?: number;
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
/** Format: int32 */
numberOfElements?: number;
empty?: boolean;
};
PageableObject: {
@@ -2388,32 +2380,15 @@ export interface components {
/** Format: int32 */
totalPages?: number;
};
DocumentListItem: {
/** Format: uuid */
id: string;
title: string;
originalFilename: string;
thumbnailUrl?: string;
/** Format: date */
documentDate?: string;
sender?: components["schemas"]["Person"];
receivers: components["schemas"]["Person"][];
tags: components["schemas"]["Tag"][];
archiveBox?: string;
archiveFolder?: string;
location?: string;
summary?: string;
DocumentSearchItem: {
document: components["schemas"]["Document"];
matchData: components["schemas"]["SearchMatchData"];
/** Format: int32 */
completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
};
DocumentSearchResult: {
items: components["schemas"]["DocumentListItem"][];
items: components["schemas"]["DocumentSearchItem"][];
/** Format: int64 */
totalElements: number;
/** Format: int32 */

View File

@@ -3,7 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock('$app/navigation');
vi.mock('$app/forms', () => ({
enhance(node: HTMLFormElement, submit?: (opts: { formData: FormData }) => unknown) {
const handler = (e: Event) => {

View File

@@ -4,7 +4,7 @@ import { page } from 'vitest/browser';
import { goto } from '$app/navigation';
import NotificationDropdown from './NotificationDropdown.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/navigation');
// Configurable result for the enhance mock — tests that need failure set
// mockFormResult.type = 'failure' before clicking.

View File

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

View File

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

View File

@@ -3,16 +3,16 @@ import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api';
type DocumentListItem = components['schemas']['DocumentListItem'];
type Document = components['schemas']['Document'];
interface Props {
documents: DocumentListItem[];
documents: Document[];
}
const { documents }: Props = $props();
function isNew(doc: DocumentListItem): boolean {
return new Date(doc.createdAt).getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000;
function isNew(doc: Document): boolean {
return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime();
}
</script>

View File

@@ -5,33 +5,24 @@ import { page } from 'vitest/browser';
import ReaderRecentDocs from './ReaderRecentDocs.svelte';
import type { components } from '$lib/generated/api';
type DocumentListItem = components['schemas']['DocumentListItem'];
type Document = components['schemas']['Document'];
afterEach(() => {
cleanup();
});
const baseDoc: DocumentListItem = {
const baseDoc: Document = {
id: 'doc1',
title: 'Brief an Hans',
originalFilename: 'brief.pdf',
completionPercentage: 0,
receivers: [],
tags: [],
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
status: 'UPLOADED',
metadataComplete: true,
scriptType: 'HANDWRITING_KURRENT',
createdAt: '2025-01-01T12:00:00Z',
updatedAt: '2025-01-01T12:00:00Z'
};
const updatedDoc: DocumentListItem = {
const updatedDoc: Document = {
...baseDoc,
id: 'doc2',
title: 'Urkunde 1920',
@@ -97,14 +88,8 @@ describe('ReaderRecentDocs', () => {
expect(thumb!.className).toMatch(/rounded-/);
});
it('shows "Neu" accent-pill badge when document was created within the last 7 days', async () => {
const recentDoc: DocumentListItem = {
...baseDoc,
id: 'doc-recent',
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString()
};
render(ReaderRecentDocs, { documents: [recentDoc] });
it('shows "Neu" accent-pill badge when createdAt equals updatedAt', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const badge = page.getByText(/^Neu$/i);
await expect.element(badge).toBeInTheDocument();
const cls = ((await badge.element()) as HTMLElement).className;
@@ -113,7 +98,7 @@ describe('ReaderRecentDocs', () => {
expect(cls).toMatch(/\btext-ink\b/);
});
it('shows no badge when document was created more than 7 days ago', async () => {
it('shows no badge when updatedAt differs from createdAt', async () => {
render(ReaderRecentDocs, { documents: [updatedDoc] });
const badge = page.getByText(/^Neu$/i);
await expect.element(badge).not.toBeInTheDocument();
@@ -121,20 +106,20 @@ describe('ReaderRecentDocs', () => {
await expect.element(updatedBadge).not.toBeInTheDocument();
});
it('shows "Neu" badge when document was created 6 days ago', async () => {
const almostOldDoc: DocumentListItem = {
it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => {
const sameInstantDoc: Document = {
...baseDoc,
id: 'doc-almost-old',
createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString()
id: 'doc-same-instant',
createdAt: '2025-01-01T12:00:00Z',
updatedAt: '2025-01-01T12:00:00.000Z'
};
render(ReaderRecentDocs, { documents: [almostOldDoc] });
render(ReaderRecentDocs, { documents: [sameInstantDoc] });
const badge = page.getByText(/^Neu$/i);
await expect.element(badge).toBeInTheDocument();
});
it('renders sender name text when sender is present', async () => {
const docWithSender: DocumentListItem = {
const docWithSender: Document = {
...baseDoc,
sender: {
id: 'p1',

View File

@@ -31,25 +31,25 @@ describe('ReaderRecentDocs', () => {
.toHaveAttribute('href', '/documents');
});
it('renders the New badge when document was created within the last 7 days', async () => {
const recentDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
const laterUpdate = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString();
it('renders the New badge when createdAt equals updatedAt', async () => {
render(ReaderRecentDocs, {
props: {
documents: [makeDoc({ createdAt: recentDate, updatedAt: laterUpdate })]
documents: [
makeDoc({ createdAt: '2026-04-15T10:00:00Z', updatedAt: '2026-04-15T10:00:00Z' })
]
}
});
await expect.element(page.getByText('Neu')).toBeVisible();
});
it('hides the New badge when document was created more than 7 days ago', async () => {
it('hides the New badge when document was updated after creation', async () => {
render(ReaderRecentDocs, {
props: {
documents: [
makeDoc({
createdAt: '2026-04-15T10:00:00Z',
updatedAt: '2026-04-15T10:00:00Z'
updatedAt: '2026-04-15T11:00:00Z'
})
]
}

View File

@@ -1,67 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import { hasAnyDocuments } from '$lib/shared/utils/tagUtils';
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
interface Props {
tags: TagTreeNodeDTO[];
compact?: boolean;
}
const MAX_VISIBLE_TAGS = 6;
const { tags, compact = false }: Props = $props();
const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments));
const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
</script>
<section class="rounded-sm border border-line bg-surface p-5 shadow-sm">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.themen_widget_title()}
</h2>
<a
href="/themen"
class="flex min-h-[44px] items-center text-[11px] font-semibold text-ink-2 no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
{m.themen_alle()}
</a>
</div>
{#if visibleTags.length === 0}
<p class="font-sans text-sm text-ink-3">{m.themen_leer()}</p>
{:else}
<div
class="grid gap-2 {compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}"
data-compact={compact}
>
{#each shownTags as tag (tag.id)}
<a
href="/documents?tag={encodeURIComponent(tag.name)}"
aria-label="{tag.name}{tag.documentCount > 0
? ', ' + m.themen_dokumente({ count: tag.documentCount })
: ''}"
class="flex cursor-pointer items-stretch overflow-hidden rounded-sm border border-line bg-canvas hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
style="min-height: 56px"
>
<span
class="w-1 flex-shrink-0 self-stretch"
aria-hidden="true"
style="background: var(--c-tag-{tag.color ?? 'slate'})"
></span>
<span class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-3 py-3">
<span class="truncate font-serif text-sm font-semibold text-ink">{tag.name}</span>
{#if tag.documentCount > 0}
<span class="font-sans text-xs text-ink-3 tabular-nums">
{m.themen_dokumente({ count: tag.documentCount })}
</span>
{/if}
</span>
</a>
{/each}
</div>
{/if}
</section>

View File

@@ -1,58 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import ThemenWidget from './ThemenWidget.svelte';
import type { components } from '$lib/generated/api';
afterEach(() => {
cleanup();
});
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
function makeTag(
name: string,
documentCount: number,
children: TagTreeNodeDTO[] = []
): TagTreeNodeDTO {
return { id: 'id-' + name, name, documentCount, children };
}
describe('ThemenWidget', () => {
it('renders a card link per visible tag', async () => {
const tags = [makeTag('Briefe', 5), makeTag('Fotos', 3)];
const { getByRole } = render(ThemenWidget, { tags });
await expect.element(getByRole('link', { name: /Briefe/ })).toBeInTheDocument();
await expect.element(getByRole('link', { name: /Fotos/ })).toBeInTheDocument();
});
it('hides tags where no document exists in the subtree', async () => {
const tags = [makeTag('Briefe', 5), makeTag('Leer', 0)];
render(ThemenWidget, { tags });
expect(document.body.textContent).toContain('Briefe');
expect(document.body.textContent).not.toContain('Leer');
});
it('shows the empty state text when all tags are filtered out', async () => {
render(ThemenWidget, { tags: [makeTag('Leer', 0)] });
expect(document.body.textContent).toMatch(/Noch keine Themen/);
});
it('shows empty state when tags array is empty', async () => {
render(ThemenWidget, { tags: [] });
expect(document.body.textContent).toMatch(/Noch keine Themen/);
});
it('renders in compact single-column mode when compact prop is true', async () => {
const tags = [makeTag('Briefe', 5)];
const { container } = render(ThemenWidget, { tags, compact: true });
const grid = container.querySelector('[data-compact="true"]');
expect(grid).not.toBeNull();
});
it('links to "Alle Themen" page', async () => {
const tags = [makeTag('Briefe', 5)];
const { getByRole } = render(ThemenWidget, { tags });
const link = getByRole('link', { name: /Alle Themen/ });
await expect.element(link).toHaveAttribute('href', '/themen');
});
});

View File

@@ -409,24 +409,19 @@ describe('PersonMentionEditor — onExit cancels pending debounce', () => {
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
const fetchesBeforeEscape = fetchMock.mock.calls.length;
// Freeze setTimeout so the 150 ms debounce cannot fire before Escape
// triggers onExit. We install fake timers only now — after the setup
// above — so that vi.waitFor()'s real-timer polling still worked.
vi.useFakeTimers();
try {
// fill() dispatches the input event synchronously via CDP; by the
// time the await resolves, onSearch('Walter') has run and the fake
// debounce timer is set.
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}');
// onExit has now called debouncedSearch.cancel(). Advance past the
// debounce window — the cancelled timer must not fire.
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS);
} finally {
vi.useRealTimers();
}
// 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(

View File

@@ -1,29 +0,0 @@
import { describe, it, expect } from 'vitest';
import { hasAnyDocuments } from './tagUtils';
import type { components } from '$lib/generated/api';
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
function makeNode(documentCount: number, children: TagTreeNodeDTO[] = []): TagTreeNodeDTO {
return { id: 'id', name: 'name', documentCount, children };
}
describe('hasAnyDocuments', () => {
it('returns false for a leaf node with documentCount=0', () => {
expect(hasAnyDocuments(makeNode(0))).toBe(false);
});
it('returns true for a leaf node with documentCount=3', () => {
expect(hasAnyDocuments(makeNode(3))).toBe(true);
});
it('returns true for a root with documentCount=0 but a child with documentCount=5', () => {
const node = makeNode(0, [makeNode(5)]);
expect(hasAnyDocuments(node)).toBe(true);
});
it('returns false for a root with documentCount=0 and all children also 0', () => {
const node = makeNode(0, [makeNode(0), makeNode(0)]);
expect(hasAnyDocuments(node)).toBe(false);
});
});

View File

@@ -1,7 +0,0 @@
import type { components } from '$lib/generated/api';
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
export function hasAnyDocuments(node: TagTreeNodeDTO): boolean {
return (node.documentCount ?? 0) > 0 || (node.children ?? []).some(hasAnyDocuments);
}

View File

@@ -10,9 +10,8 @@ type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
type DocumentListItem = components['schemas']['DocumentListItem'];
type Document = components['schemas']['Document'];
type Geschichte = components['schemas']['Geschichte'];
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
if (res?.status !== 'fulfilled') return null;
@@ -41,8 +40,7 @@ export async function load({ fetch, parent }) {
api.GET('/api/documents/search', {
params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } }
}),
api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } }),
api.GET('/api/tags/tree')
api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } })
];
if (canBlogWrite) {
readerFetches.push(
@@ -50,15 +48,14 @@ export async function load({ fetch, parent }) {
);
}
const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, tagTreeRes, draftsRes] =
const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, draftsRes] =
await Promise.allSettled(readerFetches);
const readerStats = settled<StatsDTO>(statsRes);
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? [];
const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes);
const recentDocs = searchData?.items ?? [];
const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes);
const recentDocs = searchData?.items.map((i) => i.document) ?? [];
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? [];
const drafts = settled<Geschichte[]>(draftsRes) ?? [];
return {
@@ -68,7 +65,6 @@ export async function load({ fetch, parent }) {
topPersons,
recentDocs,
recentStories,
tagTree,
drafts,
error: null as string | null
};
@@ -84,8 +80,7 @@ export async function load({ fetch, parent }) {
readyResult,
weeklyStatsResult,
incompleteResult,
incompleteCountResult,
tagTreeResult
incompleteCountResult
] = await Promise.allSettled([
api.GET('/api/stats'),
api.GET('/api/dashboard/resume'),
@@ -96,8 +91,7 @@ export async function load({ fetch, parent }) {
api.GET('/api/transcription/ready-to-read'),
api.GET('/api/transcription/weekly-stats'),
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
api.GET('/api/documents/incomplete-count'),
api.GET('/api/tags/tree')
api.GET('/api/documents/incomplete-count')
]);
let stats: StatsDTO | null = null;
@@ -110,7 +104,6 @@ export async function load({ fetch, parent }) {
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
let incompleteDocs: IncompleteDocumentDTO[] = [];
let incompleteTotal = 0;
let tagTree: TagTreeNodeDTO[] = [];
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
stats = statsResult.value.data ?? null;
@@ -142,9 +135,6 @@ export async function load({ fetch, parent }) {
if (incompleteCountResult.status === 'fulfilled' && incompleteCountResult.value.response.ok) {
incompleteTotal = (incompleteCountResult.value.data?.count as number | undefined) ?? 0;
}
if (tagTreeResult.status === 'fulfilled' && tagTreeResult.value.response.ok) {
tagTree = (tagTreeResult.value.data as TagTreeNodeDTO[]) ?? [];
}
return {
isReader: false as const,
@@ -158,7 +148,6 @@ export async function load({ fetch, parent }) {
weeklyStats,
incompleteDocs,
incompleteTotal,
tagTree,
error: null as string | null
};
} catch (e) {
@@ -178,9 +167,8 @@ export async function load({ fetch, parent }) {
incompleteTotal: 0,
readerStats: null,
topPersons: [] as PersonSummaryDTO[],
recentDocs: [] as DocumentListItem[],
recentDocs: [] as Document[],
recentStories: [] as Geschichte[],
tagTree: [] as TagTreeNodeDTO[],
drafts: [] as Geschichte[],
error: 'Daten konnten nicht geladen werden.' as string | null
};

View File

@@ -10,7 +10,6 @@ import ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte';
import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte';
import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte';
import ReaderRecentStories from '$lib/shared/dashboard/ReaderRecentStories.svelte';
import ThemenWidget from '$lib/shared/dashboard/ThemenWidget.svelte';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -46,8 +45,6 @@ const greetingText = $derived.by(() => {
<ReaderPersonChips persons={data.topPersons ?? []} />
<ThemenWidget tags={data.tagTree ?? []} />
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
<ReaderRecentDocs documents={data.recentDocs ?? []} />
<ReaderRecentStories stories={data.recentStories ?? []} />
@@ -59,40 +56,36 @@ const greetingText = $derived.by(() => {
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
</div>
{/if}
<div class="flex flex-col gap-5">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="flex flex-col gap-5">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<ThemenWidget tags={data.tagTree ?? []} />
<EnrichmentBlock
topDocs={data.incompleteDocs ?? []}
totalCount={data.incompleteTotal ?? 0}
bannerCount={bannerCount}
onBannerClose={() => (bannerCount = 0)}
/>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="flex flex-col gap-5">
<EnrichmentBlock
topDocs={data.incompleteDocs ?? []}
totalCount={data.incompleteTotal ?? 0}
bannerCount={bannerCount}
onBannerClose={() => (bannerCount = 0)}
<section aria-label={m.dashboard_mission_caption()}>
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_mission_caption()}
</h2>
<MissionControlStrip
segmentationDocs={data.segmentationDocs ?? []}
transcriptionDocs={data.transcriptionDocs ?? []}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/>
</section>
</div>
<section aria-label={m.dashboard_mission_caption()}>
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_mission_caption()}
</h2>
<MissionControlStrip
segmentationDocs={data.segmentationDocs ?? []}
transcriptionDocs={data.transcriptionDocs ?? []}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/>
</section>
</div>
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
<DashboardFamilyPulse pulse={data.pulse ?? null} />
<DashboardActivityFeed feed={data.activityFeed ?? []} />
{#if data.canWrite}
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
{/if}
</div>
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
<DashboardFamilyPulse pulse={data.pulse ?? null} />
<DashboardActivityFeed feed={data.activityFeed ?? []} />
{#if data.canWrite}
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
{/if}
</div>
</div>
{/if}

View File

@@ -5,7 +5,7 @@ import DocumentRow from '$lib/document/DocumentRow.svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { components } from '$lib/generated/api';
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
type SortMode = 'DATE' | 'TITLE' | 'SENDER' | 'RECEIVER' | 'UPLOAD_DATE' | 'RELEVANCE';
@@ -17,7 +17,7 @@ let {
q = '',
sort = 'DATE'
}: {
items: DocumentListItem[];
items: DocumentSearchItem[];
canWrite: boolean;
error?: string | null;
total?: number;
@@ -31,10 +31,10 @@ const groups = $derived.by(() => {
return groupByYear(items);
});
function groupByYear(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentListItem[]>();
function groupByYear(docItems: DocumentSearchItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>();
for (const item of docItems) {
const label = item.documentDate?.substring(0, 4) ?? m.docs_group_undated();
const label = item.document.documentDate?.substring(0, 4) ?? m.docs_group_undated();
const bucket = map.get(label);
if (bucket) bucket.push(item);
else map.set(label, [item]);
@@ -42,10 +42,10 @@ function groupByYear(docItems: DocumentListItem[]) {
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
}
function groupBySender(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentListItem[]>();
function groupBySender(docItems: DocumentSearchItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>();
for (const item of docItems) {
const label = item.sender?.displayName ?? m.docs_group_unknown_sender();
const label = item.document.sender?.displayName ?? m.docs_group_unknown_sender();
const bucket = map.get(label);
if (bucket) bucket.push(item);
else map.set(label, [item]);
@@ -53,10 +53,10 @@ function groupBySender(docItems: DocumentListItem[]) {
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
}
function groupByReceiver(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentListItem[]>();
function groupByReceiver(docItems: DocumentSearchItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>();
for (const item of docItems) {
const receivers = item.receivers ?? [];
const receivers = item.document.receivers ?? [];
const labels =
receivers.length > 0
? receivers.map((r) => r.displayName)
@@ -99,7 +99,7 @@ function groupByReceiver(docItems: DocumentListItem[]) {
>
</div>
<ul class="divide-y divide-line">
{#each group.items as item (group.label + '-' + item.id)}
{#each group.items as item (group.label + '-' + item.document.id)}
<DocumentRow item={item} canWrite={canWrite} />
{/each}
</ul>

View File

@@ -4,21 +4,28 @@ import { page } from 'vitest/browser';
import DocumentList from './DocumentList.svelte';
import type { components } from '$lib/generated/api';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/navigation');
afterEach(() => cleanup());
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem {
return {
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
documentDate: '2024-03-15',
sender: undefined,
receivers: [],
tags: [],
document: {
id: '1',
title: 'Testbrief',
originalFilename: 'testbrief.pdf',
status: 'UPLOADED',
documentDate: '2024-03-15',
sender: undefined,
receivers: [],
tags: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
metadataComplete: false,
scriptType: 'UNKNOWN'
},
matchData: {
titleOffsets: [],
senderMatched: false,
@@ -68,8 +75,8 @@ describe('DocumentList empty state', () => {
describe('DocumentList year grouping', () => {
it('groups documents by year into separate cards', async () => {
const items = [
makeItem({ id: '1', documentDate: '1923-04-12' }),
makeItem({ id: '2', documentDate: '1965-08-03' })
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1923-04-12' } }),
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1965-08-03' } })
];
render(DocumentList, { ...baseProps, items, total: 2 });
const groupCards = page.getByTestId('group-card');
@@ -78,15 +85,17 @@ describe('DocumentList year grouping', () => {
});
it('uses undated label for items with no documentDate', async () => {
const items = [makeItem({ id: '1', documentDate: undefined })];
const items = [
makeItem({ document: { ...makeItem().document, id: '1', documentDate: undefined } })
];
render(DocumentList, { ...baseProps, items, total: 1 });
await expect.element(page.getByText('Undatiert')).toBeInTheDocument();
});
it('single year renders one group-card', async () => {
const items = [
makeItem({ id: '1', documentDate: '1938-01-01' }),
makeItem({ id: '2', documentDate: '1938-06-15' })
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1938-01-01' } }),
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1938-06-15' } })
];
render(DocumentList, { ...baseProps, items, total: 2 });
const groupCards = page.getByTestId('group-card');
@@ -99,7 +108,9 @@ describe('DocumentList year grouping', () => {
describe('DocumentList sort fallback', () => {
it('falls back to year grouping when sort is not SENDER or RECEIVER', async () => {
const items = [makeItem({ id: '1', documentDate: '2024-03-15' })];
const items = [
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '2024-03-15' } })
];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'TITLE' });
await expect
.element(page.getByTestId('group-header').filter({ hasText: '2024' }))
@@ -113,23 +124,29 @@ describe('DocumentList sender grouping', () => {
it('groups by sender displayName when sort is SENDER', async () => {
const items = [
makeItem({
id: '1',
sender: {
id: 's1',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
document: {
...makeItem().document,
id: '1',
sender: {
id: 's1',
lastName: 'Mustermann',
displayName: 'Max Mustermann',
personType: 'PERSON',
familyMember: false
}
}
}),
makeItem({
id: '2',
sender: {
id: 's2',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
document: {
...makeItem().document,
id: '2',
sender: {
id: 's2',
lastName: 'Musterfrau',
displayName: 'Anna Musterfrau',
personType: 'PERSON',
familyMember: false
}
}
})
];
@@ -150,7 +167,10 @@ describe('DocumentList sender grouping', () => {
personType: 'PERSON' as const,
familyMember: false
};
const items = [makeItem({ id: '1', sender }), makeItem({ id: '2', sender })];
const items = [
makeItem({ document: { ...makeItem().document, id: '1', sender } }),
makeItem({ document: { ...makeItem().document, id: '2', sender } })
];
render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' });
const cards = page.getByTestId('group-card');
await expect.element(cards.first()).toBeInTheDocument();
@@ -158,7 +178,7 @@ describe('DocumentList sender grouping', () => {
});
it('places items with no sender under fallback label', async () => {
const items = [makeItem({ id: '1', sender: undefined })];
const items = [makeItem({ document: { ...makeItem().document, id: '1', sender: undefined } })];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'SENDER' });
await expect.element(page.getByText('Unbekannter Absender')).toBeInTheDocument();
});
@@ -170,16 +190,19 @@ describe('DocumentList receiver grouping', () => {
it('groups by receiver displayName when sort is RECEIVER', async () => {
const items = [
makeItem({
id: '1',
receivers: [
{
id: 'r1',
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON',
familyMember: false
}
]
document: {
...makeItem().document,
id: '1',
receivers: [
{
id: 'r1',
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON',
familyMember: false
}
]
}
})
];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
@@ -191,24 +214,27 @@ describe('DocumentList receiver grouping', () => {
it('duplicates a document into each receiver group', async () => {
const items = [
makeItem({
id: '1',
title: 'Rundbriefchen',
receivers: [
{
id: 'r1',
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON',
familyMember: false
},
{
id: 'r2',
lastName: 'Meier',
displayName: 'Hans Meier',
personType: 'PERSON',
familyMember: false
}
]
document: {
...makeItem().document,
id: '1',
title: 'Rundbriefchen',
receivers: [
{
id: 'r1',
lastName: 'Brandt',
displayName: 'Felix Brandt',
personType: 'PERSON',
familyMember: false
},
{
id: 'r2',
lastName: 'Meier',
displayName: 'Hans Meier',
personType: 'PERSON',
familyMember: false
}
]
}
})
];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
@@ -223,7 +249,7 @@ describe('DocumentList receiver grouping', () => {
});
it('places items with no receivers under fallback label', async () => {
const items = [makeItem({ id: '1', receivers: [] })];
const items = [makeItem({ document: { ...makeItem().document, id: '1', receivers: [] } })];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
await expect.element(page.getByText('Unbekannter Empfänger')).toBeInTheDocument();
});
@@ -235,7 +261,7 @@ describe('DocumentList DocumentRow delegation', () => {
it('shows transcription snippet when matchData has one', async () => {
const items = [
makeItem({
id: 'doc1',
document: { ...makeItem().document, id: 'doc1' },
matchData: {
transcriptionSnippet: 'Er schrieb einen langen Brief',
titleOffsets: [],
@@ -252,7 +278,7 @@ describe('DocumentList DocumentRow delegation', () => {
});
it('does not render snippet when matchData has no transcription snippet', async () => {
const items = [makeItem({ id: 'doc1' })];
const items = [makeItem({ document: { ...makeItem().document, id: 'doc1' } })];
render(DocumentList, { ...baseProps, items, total: 1 });
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
});
@@ -260,8 +286,7 @@ describe('DocumentList DocumentRow delegation', () => {
it('renders mark for title highlight when titleOffsets present', async () => {
const items = [
makeItem({
id: 'doc1',
title: 'Brief an Anna',
document: { ...makeItem().document, id: 'doc1', title: 'Brief an Anna' },
matchData: {
titleOffsets: [{ start: 0, length: 5 }], // "Brief"
senderMatched: false,

View File

@@ -2,64 +2,35 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
vi.mock('$app/navigation');
const { default: DocumentList } = await import('./DocumentList.svelte');
afterEach(cleanup);
const sender = {
id: 's1',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON' as const,
familyMember: false
};
const receiver = {
id: 'r1',
lastName: 'Meier',
displayName: 'Bert Meier',
personType: 'PERSON' as const,
familyMember: false
};
const emptyMatchData = {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
};
const sender = { id: 's1', displayName: 'Anna Schmidt' };
const receiver = { id: 'r1', displayName: 'Bert Meier' };
const makeItem = (overrides: Record<string, unknown> = {}) => ({
id: 'd1',
title: 'Brief 1923',
originalFilename: 'b.pdf',
documentDate: '1923-04-15',
sender,
receivers: [receiver],
tags: [],
summary: undefined,
archiveBox: undefined,
archiveFolder: undefined,
location: undefined,
matchData: emptyMatchData,
document: {
id: 'd1',
title: 'Brief 1923',
originalFilename: 'b.pdf',
documentDate: '1923-04-15',
sender,
receivers: [receiver],
tags: [],
thumbnailUrl: null,
contentType: 'application/pdf',
summary: null,
archiveBox: null,
archiveFolder: null,
location: null,
...overrides
},
matchData: null,
completionPercentage: 0,
contributors: [],
...overrides
contributors: []
});
describe('DocumentList', () => {
@@ -104,26 +75,8 @@ describe('DocumentList', () => {
render(DocumentList, {
props: {
items: [
makeItem({
id: 'd1',
sender: {
id: 's1',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON',
familyMember: false
}
}),
makeItem({
id: 'd2',
sender: {
id: 's2',
lastName: 'Meier',
displayName: 'Bert Meier',
personType: 'PERSON',
familyMember: false
}
})
makeItem({ id: 'd1', sender: { id: 's1', displayName: 'Anna Schmidt' } }),
makeItem({ id: 'd2', sender: { id: 's2', displayName: 'Bert Meier' } })
],
canWrite: false,
sort: 'SENDER' as const

View File

@@ -1,13 +1,11 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { invalidateAll } from '$app/navigation';
import DropZone from './DropZone.svelte';
// 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 }));
vi.mock('$app/navigation');
afterEach(() => {
cleanup();
@@ -68,7 +66,7 @@ describe('DropZone onUploadComplete', () => {
// invalidateAll is the last async step of the upload handler — once it
// has been called, the callback decision has already been made.
await vi.waitFor(() => {
expect(invalidateAllMock).toHaveBeenCalled();
expect(vi.mocked(invalidateAll)).toHaveBeenCalled();
});
expect(onUploadComplete).not.toHaveBeenCalled();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ async function resolvePersonName(
}
}
type DocumentListItem = components['schemas']['DocumentListItem'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
type ValidSort = (typeof VALID_SORTS)[number];
@@ -77,7 +77,7 @@ export async function load({ url, fetch }) {
]);
} catch {
return {
items: [] as DocumentListItem[],
items: [] as DocumentSearchItem[],
totalElements: 0,
pageNumber: 0,
pageSize: PAGE_SIZE,
@@ -107,7 +107,7 @@ export async function load({ url, fetch }) {
: null;
return {
items: (result.data?.items ?? []) as DocumentListItem[],
items: (result.data?.items ?? []) as DocumentSearchItem[],
totalElements: result.data?.totalElements ?? 0,
pageNumber: result.data?.pageNumber ?? page,
pageSize: result.data?.pageSize ?? PAGE_SIZE,

View File

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

View File

@@ -1,21 +1,9 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { goto } from '$app/navigation';
const gotoSpy = vi.fn();
vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: gotoSpy,
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
vi.mock('$app/navigation');
const { bulkSelectionStore } = await import('$lib/document/bulkSelection.svelte');
const { default: BulkEditPage } = await import('./+page.svelte');
@@ -23,14 +11,14 @@ const { default: BulkEditPage } = await import('./+page.svelte');
afterEach(() => {
cleanup();
bulkSelectionStore.clear();
gotoSpy.mockClear();
vi.mocked(goto).mockClear();
});
describe('documents/bulk-edit page', () => {
it('redirects to /documents when no documents are selected', async () => {
render(BulkEditPage, { props: {} });
await vi.waitFor(() => expect(gotoSpy).toHaveBeenCalledWith('/documents'));
await vi.waitFor(() => expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents'));
});
it('shows the loading spinner while fetching batch metadata', async () => {

View File

@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/navigation');
vi.mock('$app/state', () => ({ navigating: { to: null } }));
import Page from './+page.svelte';

View File

@@ -4,19 +4,7 @@ import { page } from 'vitest/browser';
const mockNavigating = { to: null };
vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
vi.mock('$app/navigation');
vi.mock('$app/state', () => ({
get navigating() {
@@ -140,12 +128,15 @@ describe('documents/+ page', () => {
data: baseData({
items: [
{
id: 'd1',
title: 'Brief 1899',
documentDate: '1899-04-14',
originalFilename: 'b1.pdf',
receivers: [],
tags: [],
document: {
id: 'd1',
title: 'Brief 1899',
status: 'TRANSCRIBED',
documentDate: '1899-04-14',
summary: '',
originalFilename: 'b1.pdf',
receivers: []
},
matchData: {
titleOffsets: [],
senderMatched: false,

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/navigation');
vi.mock('$app/state', () => ({ navigating: { to: null } }));
import Page from './+page.svelte';

View File

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

View File

@@ -108,8 +108,7 @@ describe('home page load — dashboard', () => {
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
}) // weekly-stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }) // incomplete-count
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
@@ -147,8 +146,7 @@ describe('home page load — dashboard', () => {
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
}) // weekly-stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }) // incomplete-count
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
@@ -396,56 +394,6 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
expect(result.isReader).toBe(false);
});
it('maps search result items directly to recentDocs without wrapping in a .document property', async () => {
const searchItem = {
id: 'd1',
title: 'Liebesbrief',
originalFilename: 'letter.pdf',
completionPercentage: 80,
receivers: [],
tags: [],
contributors: [],
matchData: { titleOffsets: [], senderMatched: false },
createdAt: '2026-05-01T10:00:00Z',
updatedAt: '2026-05-10T08:00:00Z'
};
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // initial persons
.mockResolvedValueOnce({
response: { ok: true },
data: { totalDocuments: 1, totalPersons: 1 }
}) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // topPersons
.mockResolvedValueOnce({
response: { ok: true },
data: { items: [searchItem], totalElements: 1, pageNumber: 0, pageSize: 5, totalPages: 1 }
}) // search
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // stories
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);
const result = await load({
url: makeUrl(),
request: new Request('http://localhost/'),
fetch: vi.fn() as unknown as typeof fetch,
parent: vi
.fn()
.mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false })
} as Parameters<typeof load>[0]);
expect(result.isReader).toBe(true);
if (result.isReader) {
expect(result.recentDocs).toHaveLength(1);
expect(result.recentDocs[0]).toBeDefined();
expect(result.recentDocs[0].id).toBe('d1');
expect(result.recentDocs[0].createdAt).toBe('2026-05-01T10:00:00Z');
expect(result.recentDocs[0].updatedAt).toBe('2026-05-10T08:00:00Z');
}
});
it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => {
const okStats = {
response: { ok: true, status: 200 },
@@ -461,8 +409,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
.mockResolvedValueOnce(okStats)
.mockReturnValueOnce(failPersons)
.mockResolvedValueOnce(okSearch)
.mockResolvedValueOnce(okStories)
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // tags/tree
.mockResolvedValueOnce(okStories);
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
>);

View File

@@ -8,7 +8,7 @@ type User = components['schemas']['AppUser'];
afterEach(cleanup);
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
vi.mock('$app/navigation');
const baseUser: User = {
id: 'u1',

View File

@@ -5,7 +5,7 @@ import Page from './+page.svelte';
const tick = () => new Promise((r) => setTimeout(r, 0));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$app/navigation');
const makePerson = (overrides = {}) => ({
id: '1',

View File

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

View File

@@ -1,12 +0,0 @@
import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/shared/api.server';
import type { components } from '$lib/generated/api';
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
export async function load({ fetch }: Parameters<import('./$types').PageServerLoad>[0]) {
const api = createApiClient(fetch);
const result = await api.GET('/api/tags/tree');
if (!result.response.ok) throw error(500, 'Themen konnten nicht geladen werden.');
return { tree: (result.data ?? []) as TagTreeNodeDTO[] };
}

View File

@@ -1,85 +0,0 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import { hasAnyDocuments } from '$lib/shared/utils/tagUtils';
import type { components } from '$lib/generated/api';
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
const MAX_VISIBLE_CHILDREN = 5;
let { data }: { data: { tree: TagTreeNodeDTO[] } } = $props();
const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
</script>
<svelte:head>
<title>{m.themen_widget_title()}</title>
</svelte:head>
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-6 flex items-center gap-3">
<BackButton />
<h1 class="font-serif text-2xl font-semibold text-ink">{m.themen_widget_title()}</h1>
</div>
{#if visibleTree.length === 0}
<p class="font-sans text-sm text-ink-3">{m.themen_leer()}</p>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each visibleTree as tag (tag.id)}
{@const visibleChildren = (tag.children ?? []).filter(hasAnyDocuments)}
{@const shownChildren = visibleChildren.slice(0, MAX_VISIBLE_CHILDREN)}
{@const hiddenCount = visibleChildren.length - shownChildren.length}
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<div
class="h-1.5 w-full flex-shrink-0"
aria-hidden="true"
style="background: var(--c-tag-{tag.color ?? 'slate'})"
></div>
<a
href="/documents?tag={encodeURIComponent(tag.name)}"
aria-label="{tag.name}{tag.documentCount > 0
? ', ' + m.themen_dokumente({ count: tag.documentCount })
: ''}"
class="flex min-h-[56px] items-center justify-between px-4 pt-4 pb-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
>
<span class="font-serif text-base font-semibold text-ink">{tag.name}</span>
<span class="mr-1 ml-auto font-sans text-sm text-ink-3 tabular-nums">
{#if tag.documentCount > 0}{tag.documentCount}{/if}
</span>
<span aria-hidden="true" class="h-3.5 w-3.5 flex-shrink-0 text-brand-mint"></span>
</a>
{#if shownChildren.length > 0}
<div class="mx-4 border-t border-line"></div>
{#each shownChildren as child (child.id)}
<a
href="/documents?tag={encodeURIComponent(child.name)}"
class="flex min-h-[44px] items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
>
<span class="font-sans text-sm text-ink">{child.name}</span>
<span class="mr-1 ml-auto font-sans text-xs text-ink-3 tabular-nums">
{#if child.documentCount > 0}{child.documentCount}{/if}
</span>
<span aria-hidden="true" class="h-3 w-3 flex-shrink-0 text-brand-mint"></span>
</a>
{/each}
{#if hiddenCount > 0}
<a
href="/documents?tag={encodeURIComponent(tag.name)}"
class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 hover:bg-canvas hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
>
{m.themen_weitere({ count: hiddenCount })}
</a>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
</main>

View File

@@ -1,60 +0,0 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(),
extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code
}));
import { createApiClient } from '$lib/shared/api.server';
beforeEach(() => vi.clearAllMocks());
function mockApiGet(ok: boolean, data: unknown) {
vi.mocked(createApiClient).mockReturnValue({
GET: vi.fn().mockResolvedValue({ response: { ok }, data })
} as ReturnType<typeof createApiClient>);
}
const makeTag = (name: string, documentCount = 0) => ({
id: 'id-' + name,
name,
documentCount,
children: []
});
describe('/themen +page.server load', () => {
function makeLoadEvent() {
return {
fetch: vi.fn() as unknown as typeof fetch,
request: new Request('http://localhost/themen'),
url: new URL('http://localhost/themen')
};
}
it('returns tag tree when API succeeds', async () => {
const tree = [makeTag('Briefe', 5), makeTag('Fotos', 3)];
mockApiGet(true, tree);
const { load } = await import('./+page.server');
const result = await load(makeLoadEvent());
expect(result.tree).toEqual(tree);
});
it('returns empty array when API returns empty list', async () => {
mockApiGet(true, []);
const { load } = await import('./+page.server');
const result = await load(makeLoadEvent());
expect(result.tree).toEqual([]);
});
it('throws 500 when API call fails', async () => {
mockApiGet(false, null);
const { load } = await import('./+page.server');
await expect(load(makeLoadEvent())).rejects.toMatchObject({ status: 500 });
});
});

View File

@@ -1,57 +0,0 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import ThemenPage from './+page.svelte';
import type { components } from '$lib/generated/api';
afterEach(() => {
cleanup();
});
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
function makeTag(
name: string,
documentCount: number,
children: TagTreeNodeDTO[] = []
): TagTreeNodeDTO {
return { id: 'id-' + name, name, documentCount, children };
}
describe('/themen +page', () => {
it('renders one card per visible root tag', async () => {
const tree = [makeTag('Briefe', 5), makeTag('Fotos', 3)];
render(ThemenPage, { data: { tree } });
expect(document.body.textContent).toContain('Briefe');
expect(document.body.textContent).toContain('Fotos');
});
it('does not render a tag with no documents in its subtree', async () => {
const tree = [makeTag('Briefe', 5), makeTag('Leer', 0)];
render(ThemenPage, { data: { tree } });
expect(document.body.textContent).not.toContain('Leer');
});
it('shows empty state when all tags filtered out', async () => {
render(ThemenPage, { data: { tree: [makeTag('Leer', 0)] } });
expect(document.body.textContent).toMatch(/Noch keine Themen/);
});
it('shows empty state when tree is empty', async () => {
render(ThemenPage, { data: { tree: [] } });
expect(document.body.textContent).toMatch(/Noch keine Themen/);
});
it('renders child tags for a root tag', async () => {
const tree = [makeTag('Briefe', 5, [makeTag('Brautbriefe', 3), makeTag('Kriegsbriefe', 2)])];
render(ThemenPage, { data: { tree } });
expect(document.body.textContent).toContain('Brautbriefe');
expect(document.body.textContent).toContain('Kriegsbriefe');
});
it('shows "+ N weitere" when a root tag has more than 5 children', async () => {
const children = Array.from({ length: 7 }, (_, i) => makeTag(`Kind${i}`, i + 1));
const tree = [makeTag('Briefe', 10, children)];
render(ThemenPage, { data: { tree } });
expect(document.body.textContent).toMatch(/\+\s*2\s*weitere/);
});
});

View File

@@ -1,702 +0,0 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "grafana" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Product owner overview — system health, user activity, archive progress, and OCR quality at a weekly glance.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
"id": 100,
"title": "System Health",
"type": "row",
"panels": []
},
{
"id": 1,
"title": "Backend Status",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
"targets": [
{
"expr": "up{job=\"spring-boot\"}",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" } } },
{ "type": "value", "options": { "1": { "text": "UP", "color": "green" } } }
],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "value"
}
},
{
"id": 2,
"title": "Server Errors (5xx)",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
"targets": [
{
"expr": "sum(increase(http_server_requests_seconds_count{status=~\"5..\"}[$__range]))",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 6 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 3,
"title": "Response Time (p95)",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[$__range])) by (le))",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"decimals": 2,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.5 },
{ "color": "red", "value": 2 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 4,
"title": "Error Log Count",
"type": "stat",
"datasource": { "type": "loki", "uid": "loki" },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
"targets": [
{
"expr": "sum(count_over_time({compose_service=\"backend\"} | json | level=\"ERROR\" [$__range]))",
"queryType": "instant",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 10 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 5,
"title": "CPU Usage",
"type": "bargauge",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 8, "x": 0, "y": 5 },
"targets": [
{
"expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"id": 6,
"title": "Memory Usage",
"type": "bargauge",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 8, "x": 8, "y": 5 },
"targets": [
{
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"id": 7,
"title": "Disk Usage",
"type": "bargauge",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 8, "x": 16, "y": 5 },
"targets": [
{
"expr": "(1 - (node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"})) * 100",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 80 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 10 },
"id": 101,
"title": "User Activity",
"type": "row",
"panels": []
},
{
"id": 8,
"title": "Active Users",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 11 },
"targets": [
{
"rawSql": "SELECT COUNT(DISTINCT actor_id) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 9,
"title": "Total Logins",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 11 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 10,
"title": "Failed Login Attempts",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 11 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind IN ('LOGIN_FAILED', 'LOGIN_RATE_LIMITED')",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 4 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 11,
"title": "Daily Logins (last 7 days)",
"type": "barchart",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 7, "w": 24, "x": 0, "y": 15 },
"targets": [
{
"rawSql": "SELECT DATE_TRUNC('day', happened_at) AS time, COUNT(*) AS logins FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'LOGIN_SUCCESS' GROUP BY 1 ORDER BY 1",
"format": "time_series",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"legend": { "displayMode": "hidden" },
"orientation": "auto",
"showValue": "auto",
"stacking": "none",
"xTickLabelRotation": 0,
"xTickLabelSpacing": 0
}
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 },
"id": 102,
"title": "Archive Progress",
"type": "row",
"panels": []
},
{
"id": 12,
"title": "Transcription Coverage",
"type": "bargauge",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 5, "w": 24, "x": 0, "y": 23 },
"targets": [
{
"rawSql": "SELECT (COUNT(*) FILTER (WHERE text IS NOT NULL AND text <> ''))::float * 100.0 / NULLIF(COUNT(*), 0) AS percent_complete FROM transcription_blocks",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 25 },
{ "color": "green", "value": 75 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"showUnfilled": true
}
},
{
"id": 13,
"title": "Total Documents",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM documents WHERE status <> 'PLACEHOLDER'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 14,
"title": "Uploads This Week",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'FILE_UPLOADED'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 15,
"title": "Blocks Transcribed This Week",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'TEXT_SAVED'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 16,
"title": "Blocks Reviewed This Week",
"type": "stat",
"datasource": { "type": "postgres", "uid": "postgres" },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 28 },
"targets": [
{
"rawSql": "SELECT COUNT(*) AS value FROM audit_log WHERE happened_at >= NOW() - INTERVAL '7 days' AND kind = 'BLOCK_REVIEWED'",
"format": "table",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 32 },
"id": 103,
"title": "OCR Health",
"type": "row",
"panels": []
},
{
"id": 17,
"title": "OCR Jobs",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 33 },
"targets": [
{
"expr": "sum(increase(ocr_jobs_total[$__range]))",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0,
"color": { "mode": "fixed", "fixedColor": "blue" }
}
},
"options": {
"colorMode": "value",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 18,
"title": "OCR Page Error Rate",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 33 },
"targets": [
{
"expr": "sum(increase(ocr_skipped_pages_total[$__range])) / clamp_min(sum(increase(ocr_pages_total[$__range])), 1)",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.01 },
{ "color": "red", "value": 0.05 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 19,
"title": "Illegible Word Rate",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 33 },
"targets": [
{
"expr": "sum(increase(ocr_illegible_words_total[$__range])) / clamp_min(sum(increase(ocr_words_total[$__range])), 1)",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"decimals": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.1 },
{ "color": "red", "value": 0.25 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"id": 20,
"title": "OCR Service Status",
"type": "stat",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 33 },
"targets": [
{
"expr": "ocr_models_ready",
"instant": true,
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "NOT READY", "color": "red" } } },
{ "type": "value", "options": { "1": { "text": "READY", "color": "green" } } }
],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"color": { "mode": "thresholds" }
}
},
"options": {
"colorMode": "background",
"graphMode": "none",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "value"
}
}
],
"refresh": "",
"schemaVersion": 39,
"tags": ["po-overview", "familienarchiv"],
"templating": { "list": [] },
"time": { "from": "now-7d", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "PO Overview",
"uid": "po-overview",
"version": 1,
"weekStart": ""
}

View File

@@ -36,19 +36,3 @@ datasources:
datasourceUid: prometheus
nodeGraph:
enabled: true
# Read-only PostgreSQL datasource for the PO Overview dashboard (issue #651).
# Uses the grafana_reader role provisioned by Flyway V68. Traffic stays inside
# archiv-net, so sslmode=disable is the deliberate, accepted setting.
- name: PostgreSQL
type: postgres
uid: postgres
url: archive-db:5432
user: grafana_reader
editable: false
secureJsonData:
password: ${GRAFANA_DB_PASSWORD}
jsonData:
database: ${POSTGRES_DB}
sslmode: disable
postgresVersion: 1600

View File

@@ -16,11 +16,6 @@ GLITCHTIP_DOMAIN=https://glitchtip.archiv.raddatz.cloud
POSTGRES_USER=archiv
# Note: GRAFANA_DB_PASSWORD is a secret and is injected by CI from
# obs-secrets.env (see .env.example for the local-dev declaration).
# It is consumed by both archive-backend (Flyway V68 placeholder) and
# obs-grafana (PostgreSQL datasource).
# PostgreSQL hostname for GlitchTip db-init and workers.
# The actual value depends on the Compose project name — it is not a fixed string.
# CI sets POSTGRES_HOST in obs-secrets.env per environment:

View File

@@ -20,4 +20,7 @@ scrape_configs:
- job_name: ocr-service
metrics_path: /metrics
static_configs:
# TODO: remove or add prometheus-client to ocr-service.
# The Python OCR service does not currently expose Prometheus metrics.
# This target will show as DOWN until prometheus-client is added to ocr-service.
- targets: ['ocr:8000']

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