Compare commits
10 Commits
feat/issue
...
08c7dbcaa2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08c7dbcaa2 | ||
|
|
a3f1260c23 | ||
|
|
e75ddc318b | ||
|
|
60cb9d5ad0 | ||
|
|
1ce0638ae6 | ||
|
|
f608838f7a | ||
|
|
52a96f657d | ||
|
|
f87504fb23 | ||
|
|
99de6f1d07 | ||
|
|
432ae2ac83 |
40
.env.example
40
.env.example
@@ -26,46 +26,6 @@ PORT_MAILPIT_SMTP=1025
|
||||
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
OCR_TRAINING_TOKEN=change-me-in-production
|
||||
|
||||
# --- Observability ---
|
||||
# Optional stack — start with: docker compose -f docker-compose.observability.yml up -d
|
||||
# Requires the main stack to already be running (docker compose up -d creates archiv-net).
|
||||
# In production the stack is managed from /opt/familienarchiv/ (see docs/DEPLOYMENT.md §4).
|
||||
|
||||
# Ports for host access
|
||||
PORT_GRAFANA=3003
|
||||
PORT_GLITCHTIP=3002
|
||||
PORT_PROMETHEUS=9090
|
||||
|
||||
# Grafana admin password — change this before exposing Grafana beyond localhost
|
||||
GRAFANA_ADMIN_PASSWORD=changeme
|
||||
|
||||
# GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
|
||||
GLITCHTIP_DOMAIN=http://localhost:3002
|
||||
|
||||
# GlitchTip secret key — Django SECRET_KEY equivalent, used to sign sessions and tokens.
|
||||
# REQUIRED in production — must not be empty or 'changeme'. Fail-closed: GlitchTip will
|
||||
# refuse to start with an invalid key.
|
||||
# Generate with: python3 -c "import secrets; print(secrets.token_hex(50))"
|
||||
GLITCHTIP_SECRET_KEY=changeme-generate-a-real-secret
|
||||
|
||||
# PostgreSQL hostname for GlitchTip's db-init job and workers.
|
||||
# Override when only the staging stack is running (container name differs from archive-db).
|
||||
# Default (archive-db) is correct for production with the full stack up.
|
||||
POSTGRES_HOST=archive-db
|
||||
|
||||
# $$ escaping note: passwords in /opt/familienarchiv/.env that contain a literal '$' must
|
||||
# use '$$' so Docker Compose does not expand them as variable references.
|
||||
# Example: a password 'p@$$word' should be written as 'p@$$$$word' in the .env file.
|
||||
|
||||
# Error reporting DSNs — leave empty to disable the SDK (safe default).
|
||||
# SENTRY_DSN: backend (Spring Boot) — used by the GlitchTip/Sentry Java SDK
|
||||
SENTRY_DSN=
|
||||
SENTRY_TRACES_SAMPLE_RATE=
|
||||
# VITE_SENTRY_DSN: frontend (SvelteKit) — injected at build time via Vite
|
||||
VITE_SENTRY_DSN=
|
||||
# Sentry/GlitchTip auth token for source map upload at build time (optional)
|
||||
SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Production SMTP — uncomment and fill in to send real emails instead of catching them
|
||||
# APP_BASE_URL=https://your-domain.example.com
|
||||
# MAIL_HOST=smtp.example.com
|
||||
|
||||
@@ -2,7 +2,6 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -33,84 +32,28 @@ jobs:
|
||||
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||
working-directory: frontend
|
||||
|
||||
- name: Sync SvelteKit
|
||||
run: npx svelte-kit sync
|
||||
working-directory: frontend
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Assert no banned vi.mock patterns
|
||||
shell: bash
|
||||
run: |
|
||||
# Literal pdfjs-dist (libLoader pattern — ADR 012)
|
||||
if grep -rF "vi.mock('pdfjs-dist'" frontend/src/; then
|
||||
echo "FAIL: banned vi.mock('pdfjs-dist') pattern found — see ADR 012. Use the libLoader prop injection pattern instead."
|
||||
exit 1
|
||||
fi
|
||||
# Async factory with dynamic import in body (named mechanism — ADR 012 / #553).
|
||||
# Multiline PCRE matches `vi.mock(<arg>, async ... { ... await import(...) ... })`
|
||||
# across line breaks. __meta__ is excluded because it contains fixture strings
|
||||
# demonstrating the very pattern this check is meant to forbid.
|
||||
if grep -rPzln 'vi\.mock\([^)]+,\s*async[^{]*\{[\s\S]*?await\s+import\s*\(' \
|
||||
--include='*.spec.ts' --include='*.test.ts' \
|
||||
--exclude-dir='__meta__' \
|
||||
frontend/src/; then
|
||||
echo "FAIL: banned async vi.mock factory with dynamic import in body — see ADR 012 / #553. Use a synchronous factory + vi.hoisted instead."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Assert no (upload|download)-artifact past v3
|
||||
shell: bash
|
||||
run: |
|
||||
# Self-test: verify the regex catches v4+ and does not catch v3.
|
||||
tmp=$(mktemp)
|
||||
printf ' uses: actions/upload-artifact@v5\n' > "$tmp"
|
||||
grep -qP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|
||||
|| { echo "FAIL: guard self-test — regex missed upload-artifact@v5"; rm "$tmp"; exit 1; }
|
||||
printf ' uses: actions/upload-artifact@v3\n' > "$tmp"
|
||||
grep -qvP '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' "$tmp" \
|
||||
|| { echo "FAIL: guard self-test — regex incorrectly flagged upload-artifact@v3"; rm "$tmp"; exit 1; }
|
||||
rm "$tmp"
|
||||
# Guard: Gitea Actions (act_runner) does not implement the v4 artifact protocol.
|
||||
# Both upload-artifact and download-artifact share the same incompatibility.
|
||||
# Pin to @v3. See ADR-014 / #557.
|
||||
if grep -RPn '^\s+uses:\s+actions/(upload|download)-artifact@v[4-9]' .gitea/workflows/; then
|
||||
echo "::error::actions/(upload|download)-artifact@v4+ is unsupported on Gitea Actions (act_runner). Pin to @v3. See ADR-014 / #557."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run unit and component tests with coverage
|
||||
shell: bash
|
||||
run: |
|
||||
set -eo pipefail
|
||||
npm run test:coverage 2>&1 | tee /tmp/coverage-test-${{ github.run_id }}.log
|
||||
- name: Run unit and component tests
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
env:
|
||||
TZ: Europe/Berlin
|
||||
|
||||
# Diagnostic guard: covers the coverage run only. If `npm test` (above)
|
||||
# exits 1 with a birpc error, the named pattern appears here — not there.
|
||||
- name: Assert no birpc teardown race in coverage run
|
||||
shell: bash
|
||||
if: always()
|
||||
run: |
|
||||
if grep -qF "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log 2>/dev/null; then
|
||||
echo "FAIL: [birpc] rpc is closed teardown race detected in coverage run"
|
||||
grep -F "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}.log
|
||||
exit 1
|
||||
fi
|
||||
- name: Run coverage (server + client)
|
||||
run: npm run test:coverage
|
||||
working-directory: frontend
|
||||
env:
|
||||
TZ: Europe/Berlin
|
||||
|
||||
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||
- name: Upload coverage reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-reports
|
||||
path: |
|
||||
frontend/coverage/
|
||||
/tmp/coverage-test-${{ github.run_id }}.log
|
||||
path: frontend/coverage/
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
@@ -139,10 +82,9 @@ jobs:
|
||||
|| { echo "FAIL: /hilfe/transkription.html missing from prerender output"; exit 1; }
|
||||
echo "PASS: only /hilfe/transkription.html prerendered."
|
||||
|
||||
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unit-test-screenshots
|
||||
path: frontend/test-results/screenshots/
|
||||
@@ -197,14 +139,6 @@ jobs:
|
||||
./mvnw clean test
|
||||
working-directory: backend
|
||||
|
||||
- name: Upload surefire reports
|
||||
if: always()
|
||||
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: surefire-reports
|
||||
path: backend/target/surefire-reports/
|
||||
|
||||
# ─── fail2ban Regex Regression ────────────────────────────────────────────────
|
||||
# The filter parses Caddy's JSON access log; a Caddy upgrade that reorders
|
||||
# the JSON keys would silently break it (fail2ban-regex would return
|
||||
@@ -304,8 +238,6 @@ jobs:
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
APP_MAIL_FROM=noreply@local
|
||||
IMPORT_HOST_DIR=/tmp/dummy-import
|
||||
COMPOSE_NETWORK_NAME=test-idem-archiv-net
|
||||
EOF
|
||||
|
||||
- name: Bring up minio
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Coverage Flake Probe
|
||||
|
||||
# Manually-triggered probe for the birpc teardown race documented in ADR 012
|
||||
# / #553. Runs the full coverage suite 20× in parallel against a single SHA
|
||||
# and asserts zero `[birpc] rpc is closed` lines across every cell. Verifies
|
||||
# the acceptance criterion that the race no longer surfaces under coverage.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
coverage-flake-probe:
|
||||
name: Coverage flake probe (run ${{ matrix.run }})
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
run: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Compile Paraglide i18n
|
||||
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run unit and component tests with coverage
|
||||
shell: bash
|
||||
run: |
|
||||
set -eo pipefail
|
||||
npm run test:coverage 2>&1 | tee /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
|
||||
working-directory: frontend
|
||||
env:
|
||||
TZ: Europe/Berlin
|
||||
|
||||
- name: Assert no birpc teardown race
|
||||
shell: bash
|
||||
if: always()
|
||||
run: |
|
||||
if grep -qF "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log 2>/dev/null; then
|
||||
echo "FAIL: [birpc] rpc is closed teardown race detected in run ${{ matrix.run }}"
|
||||
grep -F "[birpc] rpc is closed" /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||
- name: Upload coverage log on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-log-run-${{ matrix.run }}
|
||||
path: /tmp/coverage-test-${{ github.run_id }}-${{ matrix.run }}.log
|
||||
@@ -30,9 +30,6 @@ name: nightly
|
||||
# STAGING_OCR_TRAINING_TOKEN
|
||||
# STAGING_APP_ADMIN_USERNAME
|
||||
# STAGING_APP_ADMIN_PASSWORD
|
||||
# GRAFANA_ADMIN_PASSWORD
|
||||
# GLITCHTIP_SECRET_KEY
|
||||
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -76,33 +73,8 @@ jobs:
|
||||
MAIL_SMTP_AUTH=false
|
||||
MAIL_STARTTLS_ENABLE=false
|
||||
APP_MAIL_FROM=noreply@staging.raddatz.cloud
|
||||
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
|
||||
POSTGRES_USER=archiv
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
EOF
|
||||
|
||||
- name: Verify backend /import:ro mount is wired
|
||||
# Regression guard for #526: the /admin/system mass-import card
|
||||
# only works when the backend service mounts the host import
|
||||
# payload at /import (read-only). If a future "compose cleanup"
|
||||
# PR drops the volumes block, mass import silently breaks again.
|
||||
# `compose config` renders both shorthand and longform mounts as
|
||||
# `target: /import` + `read_only: true`, so we assert against
|
||||
# the rendered form rather than the raw source YAML.
|
||||
run: |
|
||||
set -e
|
||||
docker compose \
|
||||
-f docker-compose.prod.yml \
|
||||
-p archiv-staging \
|
||||
--env-file .env.staging \
|
||||
--profile staging \
|
||||
config > /tmp/compose-rendered.yml
|
||||
grep -q '^[[:space:]]*target: /import$' /tmp/compose-rendered.yml \
|
||||
|| { echo "::error::backend is missing the /import bind mount (see #526)"; exit 1; }
|
||||
grep -A2 '^[[:space:]]*target: /import$' /tmp/compose-rendered.yml \
|
||||
| grep -q 'read_only: true' \
|
||||
|| { echo "::error::backend /import mount is not read-only (see #526)"; exit 1; }
|
||||
|
||||
- name: Build images
|
||||
# `--pull` forces re-fetching pinned base images so a CVE
|
||||
# re-publication of the same tag (e.g. node:20.19.0-alpine3.21,
|
||||
@@ -125,77 +97,6 @@ jobs:
|
||||
--profile staging \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Deploy observability configs
|
||||
# Copies the compose file and config tree from the workspace checkout
|
||||
# into /opt/familienarchiv/ — the permanent location that persists
|
||||
# between CI runs. Containers started in the next step bind-mount
|
||||
# from there, so a future workspace wipe cannot corrupt a running
|
||||
# config file.
|
||||
#
|
||||
# obs-secrets.env is written fresh from Gitea secrets on every run so
|
||||
# Gitea is always the single source of truth for secret rotation.
|
||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||
run: |
|
||||
rm -rf /opt/familienarchiv/infra/observability
|
||||
mkdir -p /opt/familienarchiv/infra/observability
|
||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||
POSTGRES_HOST=archiv-staging-db-1
|
||||
EOF
|
||||
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-staging)
|
||||
# and service name (db). A project rename requires updating this value.
|
||||
chmod 600 /opt/familienarchiv/obs-secrets.env
|
||||
|
||||
- name: Validate observability compose config
|
||||
# Dry-run: resolves all variable substitutions and reports any missing
|
||||
# required keys before containers start. Catches undefined variables and
|
||||
# YAML errors in config files updated by the previous step.
|
||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
config --quiet
|
||||
|
||||
- name: Start observability stack
|
||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||
# that survive workspace wipes between nightly runs (see ADR-016).
|
||||
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||
# obs-secrets.env second — later file wins on duplicate keys.
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Assert observability stack health
|
||||
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||
# This step explicitly asserts the five healthchecked critical services are
|
||||
# healthy before the smoke test proceeds.
|
||||
run: |
|
||||
set -e
|
||||
unhealthy=""
|
||||
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$status" != "healthy" ]; then
|
||||
echo "::error::$svc is not healthy (status: $status)"
|
||||
unhealthy="$unhealthy $svc"
|
||||
fi
|
||||
done
|
||||
[ -z "$unhealthy" ] || exit 1
|
||||
echo "All critical observability services are healthy"
|
||||
|
||||
- name: Reload Caddy
|
||||
# Apply any committed Caddyfile changes before smoke-testing the
|
||||
# public surface. Without this step, a Caddyfile edit lands in the
|
||||
@@ -234,38 +135,28 @@ jobs:
|
||||
# public surface works. This step catches: Caddy not reloaded, HSTS
|
||||
# header dropped, /actuator block bypassed.
|
||||
#
|
||||
# --resolve pins staging.raddatz.cloud to the Docker bridge gateway IP
|
||||
# (the host) so we do NOT depend on hairpin NAT on the host router.
|
||||
# 127.0.0.1 cannot be used: job containers run in bridge network mode
|
||||
# (runner-config.yaml), so 127.0.0.1 is the container's loopback, not
|
||||
# the host's. The bridge gateway IS the host; Caddy binds 0.0.0.0:443
|
||||
# and is therefore reachable from the container via that IP.
|
||||
# SNI still uses the public hostname so the TLS cert validates correctly.
|
||||
#
|
||||
# Gateway detection reads /proc/net/route (always present, no package
|
||||
# required) instead of `ip route` to avoid a dependency on iproute2.
|
||||
# Field $2=="00000000" is the default route; field $3 is the gateway as
|
||||
# a little-endian 32-bit hex value which awk decodes to dotted-decimal.
|
||||
# --resolve pins staging.raddatz.cloud to the runner's loopback so we
|
||||
# do NOT depend on the host router doing hairpin NAT (many SOHO
|
||||
# routers do not, or do so only after a firmware update). SNI still
|
||||
# uses the public hostname so the cert validates correctly.
|
||||
run: |
|
||||
set -e
|
||||
HOST="staging.raddatz.cloud"
|
||||
URL="https://$HOST"
|
||||
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
||||
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||
RESOLVE="--resolve $HOST:443:$HOST_IP"
|
||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||
curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
|
||||
RESOLVE="--resolve $HOST:443:127.0.0.1"
|
||||
echo "Smoke test: $URL (pinned to 127.0.0.1)"
|
||||
curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null
|
||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||
# fail this check rather than pass it silently.
|
||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
||||
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||
# Permissions-Policy denies APIs the app does not use (camera,
|
||||
# microphone, geolocation). A regression that loosens or drops the
|
||||
# header now fails the smoke step.
|
||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
||||
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||
status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||
status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||
echo "All smoke checks passed"
|
||||
|
||||
|
||||
@@ -34,9 +34,6 @@ name: release
|
||||
# MAIL_PORT
|
||||
# MAIL_USERNAME
|
||||
# MAIL_PASSWORD
|
||||
# GRAFANA_ADMIN_PASSWORD
|
||||
# GLITCHTIP_SECRET_KEY
|
||||
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -74,9 +71,6 @@ jobs:
|
||||
MAIL_SMTP_AUTH=true
|
||||
MAIL_STARTTLS_ENABLE=true
|
||||
APP_MAIL_FROM=noreply@raddatz.cloud
|
||||
IMPORT_HOST_DIR=/srv/familienarchiv-production/import
|
||||
POSTGRES_USER=archiv
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||
EOF
|
||||
|
||||
- name: Build images
|
||||
@@ -98,75 +92,6 @@ jobs:
|
||||
--env-file .env.production \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Deploy observability configs
|
||||
# Mirrors the nightly approach: copies obs compose file and config tree
|
||||
# to /opt/familienarchiv/ (permanent path, survives workspace wipes — ADR-016),
|
||||
# then writes obs-secrets.env fresh from Gitea secrets.
|
||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||
run: |
|
||||
rm -rf /opt/familienarchiv/infra/observability
|
||||
mkdir -p /opt/familienarchiv/infra/observability
|
||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||
POSTGRES_HOST=archiv-production-db-1
|
||||
EOF
|
||||
# Note: POSTGRES_HOST is derived from the Compose project name (archiv-production)
|
||||
# and service name (db). A project rename requires updating this value.
|
||||
chmod 600 /opt/familienarchiv/obs-secrets.env
|
||||
|
||||
- name: Validate observability compose config
|
||||
# Dry-run: resolves all variable substitutions and reports any missing
|
||||
# required keys before containers start. Catches undefined variables and
|
||||
# YAML errors in config files updated by the previous step.
|
||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
config --quiet
|
||||
|
||||
- name: Start observability stack
|
||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||
# that survive workspace wipes between runs (see ADR-016).
|
||||
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||
# obs-secrets.env second — later file wins on duplicate keys.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Assert observability stack health
|
||||
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||
# This step explicitly asserts the five healthchecked critical services are
|
||||
# healthy before the smoke test proceeds.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
set -e
|
||||
unhealthy=""
|
||||
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$status" != "healthy" ]; then
|
||||
echo "::error::$svc is not healthy (status: $status)"
|
||||
unhealthy="$unhealthy $svc"
|
||||
fi
|
||||
done
|
||||
[ -z "$unhealthy" ] || exit 1
|
||||
echo "All critical observability services are healthy"
|
||||
|
||||
- name: Reload Caddy
|
||||
# See nightly.yml — same rationale and mechanism: DooD job containers
|
||||
# cannot call systemctl directly; nsenter via a privileged sibling
|
||||
@@ -181,28 +106,26 @@ jobs:
|
||||
|
||||
- name: Smoke test deployed environment
|
||||
# See nightly.yml — same three checks, against the prod vhost.
|
||||
# --resolve pins to the bridge gateway IP (the host), not 127.0.0.1
|
||||
# — see nightly.yml for the full network topology explanation.
|
||||
# --resolve pins archiv.raddatz.cloud to the runner's loopback so
|
||||
# the smoke test does NOT depend on hairpin NAT on the host router.
|
||||
run: |
|
||||
set -e
|
||||
HOST="archiv.raddatz.cloud"
|
||||
URL="https://$HOST"
|
||||
HOST_IP=$(ip route show default | awk '/default/ {print $3}')
|
||||
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via 'ip route'"; exit 1; }
|
||||
RESOLVE="--resolve $HOST:443:$HOST_IP"
|
||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||
curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
|
||||
RESOLVE="--resolve $HOST:443:127.0.0.1"
|
||||
echo "Smoke test: $URL (pinned to 127.0.0.1)"
|
||||
curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null
|
||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||
# fail this check rather than pass it silently.
|
||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
||||
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||
# Permissions-Policy denies APIs the app does not use (camera,
|
||||
# microphone, geolocation). A regression that loosens or drops the
|
||||
# header now fails the smoke step.
|
||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
||||
curl -fsS $RESOLVE --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||
status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||
status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||
echo "All smoke checks passed"
|
||||
|
||||
|
||||
34
CLAUDE.md
34
CLAUDE.md
@@ -159,7 +159,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
||||
|
||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||
|
||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
|
||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) mirror in `frontend/src/lib/shared/errors.ts`, (3) add i18n keys in `messages/{de,en,es}.json`.
|
||||
|
||||
### Security / Permissions
|
||||
|
||||
@@ -202,7 +202,8 @@ frontend/src/routes/
|
||||
├── profile/ User profile settings
|
||||
├── users/[id]/ Public user profile page
|
||||
├── login/ logout/ register/
|
||||
└── forgot-password/ reset-password/
|
||||
├── forgot-password/ reset-password/
|
||||
└── demo/ Dev-only demos
|
||||
```
|
||||
|
||||
### API Client Pattern
|
||||
@@ -274,35 +275,6 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
||||
|
||||
→ See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md)
|
||||
|
||||
### Observability stack (separate compose file)
|
||||
|
||||
Run via `docker-compose.observability.yml` — requires the main stack to be running first. Full setup procedure: [docs/DEPLOYMENT.md §4](./docs/DEPLOYMENT.md#4-logs--observability).
|
||||
|
||||
| Service | Container | Default Port | Purpose |
|
||||
|---------|-----------|-------------|---------|
|
||||
| Grafana | `obs-grafana` | 3003 | Metrics / logs / traces dashboard |
|
||||
| Prometheus | `obs-prometheus` | 9090 (dev only — `127.0.0.1` bound) | Metrics store |
|
||||
| Loki | `obs-loki` | — (internal) | Log store |
|
||||
| Tempo | `obs-tempo` | — (internal) | Trace store |
|
||||
| GlitchTip | `obs-glitchtip` | 3002 | Error tracking (Sentry-compatible) |
|
||||
|
||||
### Observability env vars
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `PORT_GRAFANA` | Host port for Grafana UI (default: `3003`) |
|
||||
| `PORT_GLITCHTIP` | Host port for GlitchTip UI (default: `3002`) |
|
||||
| `PORT_PROMETHEUS` | Host port for Prometheus UI (default: `9090`) |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | Grafana `admin` login password — generate with `openssl rand -hex 32` |
|
||||
| `GLITCHTIP_SECRET_KEY` | Django secret key for GlitchTip — generate with `python3 -c "import secrets; print(secrets.token_hex(32))"` |
|
||||
| `GLITCHTIP_DOMAIN` | Public-facing base URL for GlitchTip (email links, CORS), e.g. `https://glitchtip.example.com` |
|
||||
| `SENTRY_DSN` | GlitchTip/Sentry DSN for the backend (Spring Boot) — leave empty to disable |
|
||||
| `VITE_SENTRY_DSN` | GlitchTip/Sentry DSN for the frontend (SvelteKit) — injected at build time via Vite |
|
||||
|
||||
## Observability
|
||||
|
||||
→ See [docs/OBSERVABILITY.md](./docs/OBSERVABILITY.md) — where to look for logs, traces, metrics, and errors.
|
||||
|
||||
## API Testing
|
||||
|
||||
HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client extension.
|
||||
|
||||
@@ -29,30 +29,11 @@
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- opentelemetry-spring-boot-starter:2.27.0 was built against opentelemetry-api:1.61.0,
|
||||
but Spring Boot 4.0.0 BOM only manages 1.55.0 (missing GlobalOpenTelemetry.getOrNoop()).
|
||||
Import the core OTel BOM here to override it before the Spring Boot BOM applies. -->
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry</groupId>
|
||||
<artifactId>opentelemetry-bom</artifactId>
|
||||
<version>1.61.0</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Boot 4.0 splits Micrometer metrics export (incl. Prometheus scrape endpoint) into its own starter -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-micrometer-metrics</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
@@ -216,42 +197,6 @@
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.18.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Observability: Prometheus metrics scrape endpoint (version managed by Spring Boot BOM) -->
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Observability: Micrometer → OpenTelemetry tracing bridge (version managed by Spring Boot BOM) -->
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-tracing-bridge-otel</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Observability: OTel Spring Boot auto-instrumentation — NOT in Spring Boot BOM, pinned explicitly -->
|
||||
<dependency>
|
||||
<groupId>io.opentelemetry.instrumentation</groupId>
|
||||
<artifactId>opentelemetry-spring-boot-starter</artifactId>
|
||||
<version>2.27.0</version>
|
||||
<exclusions>
|
||||
<!-- Excludes AzureAppServiceResourceProvider which references ServiceAttributes.SERVICE_INSTANCE_ID
|
||||
that does not exist in the semconv version pulled by this project. -->
|
||||
<exclusion>
|
||||
<groupId>io.opentelemetry.contrib</groupId>
|
||||
<artifactId>opentelemetry-azure-resources</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- Sentry error reporting (GlitchTip-compatible) — sentry-spring-boot-4 is the
|
||||
Spring Boot 4 / Spring Framework 7 compatible module (replaces the jakarta starter
|
||||
which crashes with SF7 due to bean-name generation for triply-nested @Import classes) -->
|
||||
<dependency>
|
||||
<groupId>io.sentry</groupId>
|
||||
<artifactId>sentry-spring-boot-4</artifactId>
|
||||
<version>8.41.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -328,16 +273,6 @@
|
||||
</profiles>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<forkedProcessTimeoutInSeconds>600</forkedProcessTimeoutInSeconds>
|
||||
<systemPropertyVariables>
|
||||
<junit.jupiter.execution.timeout.default>90 s</junit.jupiter.execution.timeout.default>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
@@ -30,8 +30,6 @@ public enum ErrorCode {
|
||||
// --- Users ---
|
||||
/** A user with the given ID or username does not exist. 404 */
|
||||
USER_NOT_FOUND,
|
||||
/** A group with the given ID does not exist. 404 */
|
||||
GROUP_NOT_FOUND,
|
||||
/** The supplied email address is already used by another account. 409 */
|
||||
EMAIL_ALREADY_IN_USE,
|
||||
/** The supplied current password does not match the stored hash. 400 */
|
||||
@@ -54,8 +52,6 @@ public enum ErrorCode {
|
||||
INVITE_REVOKED,
|
||||
/** The invite has passed its expiry date. 410 */
|
||||
INVITE_EXPIRED,
|
||||
/** A group cannot be deleted because one or more active invites reference it. 409 */
|
||||
GROUP_HAS_ACTIVE_INVITES,
|
||||
|
||||
// --- Auth ---
|
||||
/** The request is not authenticated. 401 */
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.exception;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import io.sentry.Sentry;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
@@ -64,7 +63,6 @@ public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
||||
Sentry.captureException(ex);
|
||||
log.error("Unhandled exception", ex);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(new ErrorResponse(ErrorCode.INTERNAL_ERROR, "An unexpected error occurred"));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.raddatz.familienarchiv.importing;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
@@ -53,9 +52,9 @@ public class MassImportService {
|
||||
|
||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||
|
||||
public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
|
||||
public record ImportStatus(State state, String message, int processed, LocalDateTime startedAt) {}
|
||||
|
||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "Kein Import gestartet.", 0, null);
|
||||
|
||||
public ImportStatus getStatus() {
|
||||
return currentStatus;
|
||||
@@ -100,9 +99,7 @@ public class MassImportService {
|
||||
@Value("${app.import.col.transcription:13}")
|
||||
private int colTranscription;
|
||||
|
||||
@Value("${app.import.dir:/import}")
|
||||
private String importDir;
|
||||
|
||||
private static final String IMPORT_DIR = "/import";
|
||||
private static final DateTimeFormatter GERMAN_DATE = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN);
|
||||
|
||||
// ODS XML namespaces
|
||||
@@ -117,39 +114,30 @@ public class MassImportService {
|
||||
if (currentStatus.state() == State.RUNNING) {
|
||||
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
||||
}
|
||||
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
|
||||
currentStatus = new ImportStatus(State.RUNNING, "Import läuft...", 0, LocalDateTime.now());
|
||||
try {
|
||||
File spreadsheet = findSpreadsheetFile();
|
||||
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
||||
int processed = processRows(readSpreadsheet(spreadsheet));
|
||||
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
||||
currentStatus = new ImportStatus(State.DONE,
|
||||
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
||||
processed, currentStatus.startedAt());
|
||||
} catch (NoSpreadsheetException e) {
|
||||
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
||||
} catch (Exception e) {
|
||||
log.error("Massenimport fehlgeschlagen", e);
|
||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
|
||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
||||
currentStatus = new ImportStatus(State.FAILED, "Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
||||
}
|
||||
}
|
||||
|
||||
private static class NoSpreadsheetException extends RuntimeException {
|
||||
NoSpreadsheetException(String message) { super(message); }
|
||||
}
|
||||
|
||||
private File findSpreadsheetFile() throws IOException {
|
||||
try (Stream<Path> files = Files.list(Paths.get(importDir))) {
|
||||
try (Stream<Path> files = Files.list(Paths.get(IMPORT_DIR))) {
|
||||
return files
|
||||
.filter(p -> {
|
||||
String name = p.toString().toLowerCase();
|
||||
return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls");
|
||||
})
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new NoSpreadsheetException(
|
||||
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!"))
|
||||
.orElseThrow(() -> new RuntimeException(
|
||||
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + IMPORT_DIR + " gefunden!"))
|
||||
.toFile();
|
||||
}
|
||||
}
|
||||
@@ -390,7 +378,7 @@ public class MassImportService {
|
||||
}
|
||||
|
||||
private Optional<File> findFileRecursive(String filename) {
|
||||
try (Stream<Path> walk = Files.walk(Paths.get(importDir))) {
|
||||
try (Stream<Path> walk = Files.walk(Paths.get(IMPORT_DIR))) {
|
||||
return walk.filter(p -> !Files.isDirectory(p))
|
||||
.filter(p -> p.getFileName().toString().equals(filename))
|
||||
.map(Path::toFile)
|
||||
|
||||
@@ -3,16 +3,13 @@ package org.raddatz.familienarchiv.security;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
@@ -37,28 +34,6 @@ public class SecurityConfig {
|
||||
return authProvider;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(1)
|
||||
public SecurityFilterChain managementFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.securityMatcher("/actuator/**")
|
||||
.authorizeHttpRequests(auth -> {
|
||||
// Health and Prometheus are open — Docker health checks and Prometheus scraping need no credentials.
|
||||
auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll();
|
||||
// All other actuator endpoints (metrics, info, env, heapdump…) require authentication.
|
||||
auth.anyRequest().authenticated();
|
||||
})
|
||||
// Explicitly return 401 for any unauthenticated actuator request.
|
||||
// Without this override, Spring Security's DelegatingAuthenticationEntryPoint
|
||||
// would redirect browser-like clients to the form-login page (302 → /login),
|
||||
// making it impossible to distinguish "not authenticated" from "not found" in tests.
|
||||
.exceptionHandling(ex -> ex.authenticationEntryPoint(
|
||||
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)))
|
||||
.formLogin(AbstractHttpConfigurer::disable)
|
||||
.csrf(AbstractHttpConfigurer::disable);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
@@ -79,10 +54,8 @@ public class SecurityConfig {
|
||||
.csrf(csrf -> csrf.disable())
|
||||
|
||||
.authorizeHttpRequests(auth -> {
|
||||
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above.
|
||||
// The permitAll() lines here are a belt-and-suspenders fallback in case any
|
||||
// actuator path escapes that chain's securityMatcher. See docs/adr/017.
|
||||
auth.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll();
|
||||
// Health endpoint must be open so CI/Docker health checks work without credentials
|
||||
auth.requestMatchers("/actuator/health").permitAll();
|
||||
// Password reset endpoints are unauthenticated by nature
|
||||
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
||||
// Invite-based registration endpoints are public
|
||||
|
||||
@@ -52,11 +52,7 @@ public class InviteService {
|
||||
public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) {
|
||||
Set<UUID> groupIds = new HashSet<>();
|
||||
if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) {
|
||||
Set<UUID> uniqueIds = new HashSet<>(dto.getGroupIds());
|
||||
List<UserGroup> groups = userService.findGroupsByIds(new ArrayList<>(uniqueIds));
|
||||
if (groups.size() != uniqueIds.size()) {
|
||||
throw DomainException.notFound(ErrorCode.GROUP_NOT_FOUND, "One or more group IDs do not exist");
|
||||
}
|
||||
List<UserGroup> groups = userService.findGroupsByIds(dto.getGroupIds());
|
||||
groups.forEach(g -> groupIds.add(g.getId()));
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,4 @@ public interface InviteTokenRepository extends JpaRepository<InviteToken, UUID>
|
||||
|
||||
@Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC")
|
||||
List<InviteToken> findAllOrderedByCreatedAt();
|
||||
|
||||
@Query("SELECT CASE WHEN COUNT(t) > 0 THEN true ELSE false END FROM InviteToken t JOIN t.groupIds g WHERE g = :groupId AND t.revoked = false AND (t.expiresAt IS NULL OR t.expiresAt > CURRENT_TIMESTAMP) AND (t.maxUses IS NULL OR t.useCount < t.maxUses)")
|
||||
boolean existsActiveWithGroupId(@Param("groupId") UUID groupId);
|
||||
}
|
||||
|
||||
@@ -37,9 +37,6 @@ public class UserService {
|
||||
|
||||
private final AppUserRepository userRepository;
|
||||
private final UserGroupRepository groupRepository;
|
||||
// Injected directly (not via InviteService) to avoid a constructor injection cycle:
|
||||
// InviteService → UserService → InviteService. Spring Framework 7 forbids such cycles.
|
||||
private final InviteTokenRepository inviteTokenRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AuditService auditService;
|
||||
|
||||
@@ -291,10 +288,6 @@ public class UserService {
|
||||
|
||||
@Transactional
|
||||
public void deleteGroup(UUID id) {
|
||||
if (inviteTokenRepository.existsActiveWithGroupId(id)) {
|
||||
throw DomainException.conflict(ErrorCode.GROUP_HAS_ACTIVE_INVITES,
|
||||
"Cannot delete group " + id + " — referenced by one or more active invites");
|
||||
}
|
||||
groupRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,50 +45,9 @@ server:
|
||||
forward-headers-strategy: native
|
||||
|
||||
management:
|
||||
server:
|
||||
# Management port is separate from the app port so that:
|
||||
# (a) Caddy never proxies /actuator/* (it only routes :8080 → the app port)
|
||||
# (b) Prometheus scrapes backend:8081 directly inside archiv-net, not via Caddy
|
||||
# Note: in Spring Boot 4.0 the management port shares the security filter chain; /actuator/health
|
||||
# and /actuator/prometheus must be explicitly permitted in SecurityConfig — see SecurityConfig.java.
|
||||
port: 8081
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,prometheus,metrics
|
||||
endpoint:
|
||||
prometheus:
|
||||
enabled: true
|
||||
# Spring Boot 4.0: metrics export is disabled by default — explicitly opt in for Prometheus
|
||||
prometheus:
|
||||
metrics:
|
||||
export:
|
||||
enabled: true
|
||||
metrics:
|
||||
tags:
|
||||
# Common tag applied to every metric so Grafana's Spring Boot dashboard can filter by application name.
|
||||
# Override via MANAGEMENT_METRICS_TAGS_APPLICATION env var.
|
||||
application: ${spring.application.name}
|
||||
health:
|
||||
mail:
|
||||
enabled: false
|
||||
tracing:
|
||||
sampling:
|
||||
probability: 1.0 # 100% in dev; override via MANAGEMENT_TRACING_SAMPLING_PROBABILITY in prod compose
|
||||
|
||||
# OpenTelemetry trace export — failures are non-fatal (app starts cleanly without Tempo running)
|
||||
# Port 4318 = OTLP HTTP (the default transport for Spring Boot's HttpExporter).
|
||||
# Port 4317 is gRPC-only; sending HTTP/1.1 to it produces "Connection reset".
|
||||
otel:
|
||||
service:
|
||||
name: familienarchiv-backend
|
||||
exporter:
|
||||
otlp:
|
||||
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}
|
||||
logs:
|
||||
exporter: none # Promtail captures Docker logs; disable OTLP log export (Tempo only accepts traces)
|
||||
metrics:
|
||||
exporter: none # Prometheus scrapes /actuator/prometheus; disable OTLP metric export to Tempo
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
@@ -134,12 +93,3 @@ ocr:
|
||||
sender-model:
|
||||
activation-threshold: 100
|
||||
retrain-delta: 50
|
||||
|
||||
sentry:
|
||||
dsn: ${SENTRY_DSN:}
|
||||
environment: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
traces-sample-rate: ${SENTRY_TRACES_SAMPLE_RATE:1.0}
|
||||
send-default-pii: false
|
||||
enable-tracing: true
|
||||
ignored-exceptions-for-type:
|
||||
- org.raddatz.familienarchiv.exception.DomainException
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- The composite PK (invite_token_id, group_id) does not support efficient lookups by group_id alone.
|
||||
-- Add a dedicated index to support existsActiveWithGroupId queries.
|
||||
CREATE INDEX idx_itg_group_id ON invite_token_group_ids (group_id);
|
||||
@@ -1,63 +0,0 @@
|
||||
package org.raddatz.familienarchiv;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalManagementPort;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class ActuatorPrometheusIT {
|
||||
|
||||
@LocalManagementPort
|
||||
private int managementPort;
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Test
|
||||
void prometheus_endpoint_returns_200_without_credentials() {
|
||||
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||
"http://localhost:" + managementPort + "/actuator/prometheus", String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void prometheus_endpoint_returns_jvm_metrics() {
|
||||
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||
"http://localhost:" + managementPort + "/actuator/prometheus", String.class);
|
||||
|
||||
assertThat(response.getBody()).contains("jvm_memory_used_bytes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void actuator_metrics_requires_authentication() {
|
||||
ResponseEntity<String> response = noThrowTemplate().getForEntity(
|
||||
"http://localhost:" + managementPort + "/actuator/metrics", String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
private RestTemplate noThrowTemplate() {
|
||||
RestTemplate template = new RestTemplate();
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(org.springframework.http.client.ClientHttpResponse response) throws IOException {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
package org.raddatz.familienarchiv;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@@ -21,18 +17,9 @@ class ApplicationContextTest {
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired
|
||||
ApplicationContext ctx;
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
// verifies that the Spring context starts successfully with all beans wired,
|
||||
// Flyway migrations applied, and no configuration errors
|
||||
}
|
||||
|
||||
@Test
|
||||
void sentry_is_disabled_when_no_dsn_is_configured() {
|
||||
// application-test.yaml has no sentry.dsn — SDK must stay inactive so tests are clean
|
||||
assertThat(io.sentry.Sentry.isEnabled()).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package org.raddatz.familienarchiv.audit;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
@@ -18,6 +18,7 @@ import static org.awaitility.Awaitility.await;
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class AuditServiceIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@@ -25,11 +26,6 @@ class AuditServiceIntegrationTest {
|
||||
@Autowired AuditLogRepository auditLogRepository;
|
||||
@Autowired TransactionTemplate transactionTemplate;
|
||||
|
||||
@BeforeEach
|
||||
void resetAuditLog() {
|
||||
auditLogRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logAfterCommit_writes_ANNOTATION_CREATED_row_after_transaction_commits() {
|
||||
transactionTemplate.execute(status -> {
|
||||
|
||||
@@ -12,9 +12,9 @@ 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.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
@@ -33,7 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class DocumentSearchPagedIntegrationTest {
|
||||
|
||||
private static final int FIXTURE_SIZE = 120;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package org.raddatz.familienarchiv.exception;
|
||||
|
||||
import io.sentry.Sentry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
@InjectMocks
|
||||
private GlobalExceptionHandler handler;
|
||||
|
||||
@Test
|
||||
void handleGeneric_captures_exception_in_sentry_and_returns_500() {
|
||||
RuntimeException ex = new RuntimeException("unexpected failure");
|
||||
|
||||
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
|
||||
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleGeneric(ex);
|
||||
|
||||
sentryMock.verify(() -> Sentry.captureException(ex));
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(500);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,9 @@ import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.List;
|
||||
@@ -32,7 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class GeschichteServiceIntegrationTest {
|
||||
|
||||
@MockitoBean
|
||||
|
||||
@@ -20,10 +20,7 @@ import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDate;
|
||||
@@ -53,7 +50,6 @@ class MassImportServiceTest {
|
||||
void setUp() {
|
||||
service = new MassImportService(documentService, personService, tagService, s3Client, thumbnailAsyncRunner);
|
||||
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
|
||||
ReflectionTestUtils.setField(service, "importDir", "/import");
|
||||
ReflectionTestUtils.setField(service, "colIndex", 0);
|
||||
ReflectionTestUtils.setField(service, "colBox", 1);
|
||||
ReflectionTestUtils.setField(service, "colFolder", 2);
|
||||
@@ -73,64 +69,20 @@ class MassImportServiceTest {
|
||||
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getStatus_hasStatusCode_IMPORT_IDLE_byDefault() {
|
||||
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_IDLE");
|
||||
}
|
||||
|
||||
// ─── runImportAsync ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
|
||||
// /import directory doesn't exist in test environment → IOException → IMPORT_FAILED_INTERNAL
|
||||
// /import directory doesn't exist in test environment → findSpreadsheetFile throws
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
|
||||
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_INTERNAL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_readsFromConfiguredImportDir(@TempDir Path tempDir) {
|
||||
// Empty temp dir → findSpreadsheetFile throws "no spreadsheet" with the
|
||||
// configured path in the message. Proves the field, not a constant,
|
||||
// drives the lookup.
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
|
||||
assertThat(service.getStatus().message()).contains(tempDir.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_setsStatusCode_IMPORT_FAILED_NO_SPREADSHEET_whenDirIsEmpty(@TempDir Path tempDir) {
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_NO_SPREADSHEET");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_setsStatusCode_IMPORT_DONE_whenSpreadsheetHasNoDataRows(@TempDir Path tempDir) throws Exception {
|
||||
Path xlsx = tempDir.resolve("import.xlsx");
|
||||
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||
wb.createSheet("Sheet1");
|
||||
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||
wb.write(out);
|
||||
}
|
||||
}
|
||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
||||
|
||||
service.runImportAsync();
|
||||
|
||||
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_DONE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
|
||||
MassImportService.State.RUNNING, "Running...", 0, LocalDateTime.now());
|
||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||
|
||||
assertThatThrownBy(() -> service.runImportAsync())
|
||||
|
||||
@@ -8,9 +8,9 @@ import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -18,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
|
||||
class PersonServiceIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
|
||||
@@ -40,47 +40,6 @@ class AdminControllerTest {
|
||||
@MockitoBean ThumbnailBackfillService thumbnailBackfillService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/admin/import-status ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void importStatus_returns200_withStatusCode_whenAdmin() throws Exception {
|
||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
when(massImportService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(get("/api/admin/import-status"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.state").value("IDLE"))
|
||||
.andExpect(jsonPath("$.statusCode").value("IMPORT_IDLE"))
|
||||
.andExpect(jsonPath("$.processed").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ADMIN")
|
||||
void importStatus_messageField_notPresentInApiResponse() throws Exception {
|
||||
MassImportService.ImportStatus status = new MassImportService.ImportStatus(
|
||||
MassImportService.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
||||
when(massImportService.getStatus()).thenReturn(status);
|
||||
|
||||
mockMvc.perform(get("/api/admin/import-status"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void importStatus_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/import-status"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void importStatus_returns403_whenUserLacksAdminPermission() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/import-status"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||
|
||||
@@ -20,13 +20,10 @@ 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.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -150,30 +147,6 @@ class InviteControllerTest {
|
||||
.andExpect(jsonPath("$.label").value("Für Familie"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"})
|
||||
void createInvite_forwardsGroupIdsToService() throws Exception {
|
||||
UUID groupId = UUID.randomUUID();
|
||||
AppUser admin = AppUser.builder().id(UUID.randomUUID()).email("admin@test.com").build();
|
||||
when(userService.findByEmail("admin@test.com")).thenReturn(admin);
|
||||
|
||||
InviteToken savedToken = InviteToken.builder()
|
||||
.id(UUID.randomUUID()).code("ABCDE12345").useCount(0).build();
|
||||
when(inviteService.createInvite(any(), eq(admin))).thenReturn(savedToken);
|
||||
when(inviteService.toListItemDTO(any(), anyString()))
|
||||
.thenReturn(makeInviteDTO(savedToken.getId(), "ABCDE12345"));
|
||||
|
||||
String body = "{\"groupIds\":[\"" + groupId + "\"]}";
|
||||
mockMvc.perform(post("/api/invites")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
ArgumentCaptor<CreateInviteRequest> captor = ArgumentCaptor.forClass(CreateInviteRequest.class);
|
||||
verify(inviteService).createInvite(captor.capture(), eq(admin));
|
||||
assertThat(captor.getValue().getGroupIds()).containsExactly(groupId);
|
||||
}
|
||||
|
||||
// ─── DELETE /api/invites/{id} ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -156,35 +156,6 @@ class InviteServiceTest {
|
||||
assertThat(result.getGroupIds()).contains(g.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createInvite_throwsGroupNotFound_whenSubmittedGroupIdDoesNotExist() {
|
||||
UUID unknownGroupId = UUID.randomUUID();
|
||||
when(userService.findGroupsByIds(anyList())).thenReturn(List.of());
|
||||
|
||||
CreateInviteRequest req = new CreateInviteRequest();
|
||||
req.setGroupIds(List.of(unknownGroupId));
|
||||
|
||||
assertThatThrownBy(() -> inviteService.createInvite(req, admin))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.GROUP_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createInvite_doesNotThrowGroupNotFound_whenDuplicateGroupIdsSubmitted() {
|
||||
UUID groupId = UUID.randomUUID();
|
||||
UserGroup group = UserGroup.builder().id(groupId).name("Familie").build();
|
||||
when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty());
|
||||
when(userService.findGroupsByIds(anyList())).thenReturn(List.of(group));
|
||||
when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
CreateInviteRequest req = new CreateInviteRequest();
|
||||
req.setGroupIds(List.of(groupId, groupId)); // same UUID submitted twice
|
||||
|
||||
// before deduplication: size(groups)==1 != size(submitted)==2 → false GROUP_NOT_FOUND
|
||||
assertThatCode(() -> inviteService.createInvite(req, admin)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
// ─── redeemInvite ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package org.raddatz.familienarchiv.user;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class InviteTokenRepositoryIntegrationTest {
|
||||
|
||||
@Autowired InviteTokenRepository inviteTokenRepository;
|
||||
@Autowired UserGroupRepository userGroupRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
|
||||
private UserGroup group;
|
||||
private AppUser admin;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
inviteTokenRepository.deleteAll();
|
||||
userGroupRepository.deleteAll();
|
||||
appUserRepository.deleteAll();
|
||||
admin = appUserRepository.save(AppUser.builder().email("admin@test.com").password("pw").build());
|
||||
group = userGroupRepository.save(UserGroup.builder().name("Familie").build());
|
||||
}
|
||||
|
||||
// ─── existsActiveWithGroupId ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void existsActiveWithGroupId_returnsTrueForActiveInviteLinkedToGroup() {
|
||||
inviteTokenRepository.save(token(t -> t));
|
||||
|
||||
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsActiveWithGroupId_returnsFalseWhenInviteIsRevoked() {
|
||||
inviteTokenRepository.save(token(t -> t.revoked(true)));
|
||||
|
||||
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsActiveWithGroupId_returnsFalseWhenInviteIsExpired() {
|
||||
inviteTokenRepository.save(token(t -> t.expiresAt(LocalDateTime.now().minusDays(1))));
|
||||
|
||||
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsActiveWithGroupId_returnsFalseWhenInviteIsExhausted() {
|
||||
inviteTokenRepository.save(token(t -> t.maxUses(1).useCount(1)));
|
||||
|
||||
assertThat(inviteTokenRepository.existsActiveWithGroupId(group.getId())).isFalse();
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private InviteToken token(java.util.function.UnaryOperator<InviteToken.InviteTokenBuilder> customizer) {
|
||||
InviteToken.InviteTokenBuilder builder = InviteToken.builder()
|
||||
.code(UUID.randomUUID().toString().replace("-", "").substring(0, 10))
|
||||
.groupIds(new java.util.HashSet<>(Set.of(group.getId())))
|
||||
.createdBy(admin);
|
||||
return customizer.apply(builder).build();
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,6 @@ class UserServiceTest {
|
||||
|
||||
@Mock AppUserRepository userRepository;
|
||||
@Mock UserGroupRepository groupRepository;
|
||||
@Mock InviteTokenRepository inviteTokenRepository;
|
||||
@Mock PasswordEncoder passwordEncoder;
|
||||
@Mock AuditService auditService;
|
||||
@InjectMocks UserService userService;
|
||||
@@ -904,29 +903,6 @@ class UserServiceTest {
|
||||
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
|
||||
}
|
||||
|
||||
// ─── deleteGroup ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteGroup_throwsConflict_whenActiveInviteReferencesGroup() {
|
||||
UUID groupId = UUID.randomUUID();
|
||||
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> userService.deleteGroup(groupId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.GROUP_HAS_ACTIVE_INVITES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteGroup_deletesGroup_whenNoActiveInviteReferencesGroup() {
|
||||
UUID groupId = UUID.randomUUID();
|
||||
when(inviteTokenRepository.existsActiveWithGroupId(groupId)).thenReturn(false);
|
||||
|
||||
userService.deleteGroup(groupId);
|
||||
|
||||
verify(groupRepository).deleteById(groupId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createGroup_withNullPermissions_savesGroupWithEmptyPermissionSet() {
|
||||
org.raddatz.familienarchiv.user.GroupDTO dto = new org.raddatz.familienarchiv.user.GroupDTO();
|
||||
|
||||
@@ -13,18 +13,3 @@ spring:
|
||||
password: test
|
||||
mail:
|
||||
host: localhost
|
||||
|
||||
# Disable OTel SDK entirely in tests — prevents auto-configuration from loading resource providers
|
||||
# (e.g. AzureAppServiceResourceProvider) that fail against the semconv version used here.
|
||||
otel:
|
||||
sdk:
|
||||
disabled: true
|
||||
|
||||
# Disable trace export in tests — prevents OTLP connection attempts when no Tempo is running.
|
||||
# Sampling probability 0.0 means no spans are created, so no export is attempted.
|
||||
management:
|
||||
server:
|
||||
port: 0 # random port per context — prevents TIME_WAIT conflicts when @DirtiesContext restarts the context
|
||||
tracing:
|
||||
sampling:
|
||||
probability: 0.0
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
logging.level.root=WARN
|
||||
logging.level.org.raddatz=INFO
|
||||
@@ -1,266 +0,0 @@
|
||||
# Observability stack — Grafana LGTM + GlitchTip
|
||||
#
|
||||
# Requires the main stack to be running first:
|
||||
# docker compose up -d # creates archiv-net
|
||||
# docker compose -f docker-compose.observability.yml up -d
|
||||
#
|
||||
# To validate without starting:
|
||||
# docker compose -f docker-compose.observability.yml config
|
||||
|
||||
services:
|
||||
|
||||
# --- Metrics: Prometheus ---
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v3.4.0
|
||||
container_name: obs-prometheus
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./infra/observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--storage.tsdb.retention.time=30d'
|
||||
- '--web.enable-lifecycle'
|
||||
ports:
|
||||
- "127.0.0.1:${PORT_PROMETHEUS:-9090}:9090"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:9090/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
networks:
|
||||
- archiv-net
|
||||
- obs-net
|
||||
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.9.0
|
||||
container_name: obs-node-exporter
|
||||
restart: unless-stopped
|
||||
# pid: host — required for process-level CPU/memory metrics; cgroup isolation applies
|
||||
pid: host
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.sysfs=/host/sys'
|
||||
# $$ is YAML Compose escaping for a literal $ in the regex alternation
|
||||
- '--collector.filesystem.ignored-mount-points=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
expose:
|
||||
- "9100"
|
||||
networks:
|
||||
- obs-net
|
||||
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:v0.52.1
|
||||
container_name: obs-cadvisor
|
||||
restart: unless-stopped
|
||||
# privileged: true — required for cgroup and namespace metrics, see cAdvisor docs.
|
||||
# Accepted risk: cAdvisor is pinned, on Renovate, and not exposed outside obs-net.
|
||||
privileged: true
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
# /var/run/docker.sock mounted read-only — sufficient for container metadata discovery
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker:/var/lib/docker:ro
|
||||
expose:
|
||||
- "8080"
|
||||
networks:
|
||||
- obs-net
|
||||
|
||||
# --- Logs: Loki + Promtail ---
|
||||
|
||||
loki:
|
||||
image: grafana/loki:3.4.2
|
||||
container_name: obs-loki
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./infra/observability/loki/loki-config.yml:/etc/loki/loki-config.yml:ro
|
||||
- loki_data:/loki
|
||||
command: -config.file=/etc/loki/loki-config.yml
|
||||
expose:
|
||||
- "3100"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready | grep -q ready || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- obs-net
|
||||
|
||||
promtail:
|
||||
image: grafana/promtail:3.4.2
|
||||
container_name: obs-promtail
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./infra/observability/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
# :ro restricts file-system access but NOT Docker API permissions — a compromised Promtail has full daemon access. Accepted risk on single-operator self-hosted archive.
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- promtail_positions:/tmp # persists positions.yaml across restarts — avoids duplicate log ingestion
|
||||
command: -config.file=/etc/promtail/promtail-config.yml
|
||||
networks:
|
||||
- archiv-net # label discovery from application containers via Docker socket
|
||||
- obs-net # log shipping to Loki
|
||||
depends_on:
|
||||
loki:
|
||||
condition: service_healthy
|
||||
|
||||
# --- Traces: Tempo ---
|
||||
|
||||
tempo:
|
||||
image: grafana/tempo:2.7.2
|
||||
container_name: obs-tempo
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./infra/observability/tempo/tempo.yml:/etc/tempo.yml:ro
|
||||
- tempo_data:/var/tempo
|
||||
command: -config.file=/etc/tempo.yml
|
||||
expose:
|
||||
- "3200" # Grafana queries Tempo on this port (obs-net only)
|
||||
- "4317" # OTLP gRPC — backend sends traces here (archiv-net)
|
||||
- "4318" # OTLP HTTP — alternative transport (archiv-net)
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3200/ready | grep -q ready || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
networks:
|
||||
- archiv-net # backend (archive-backend) reaches tempo:4317 over this network
|
||||
- obs-net # Grafana reaches tempo:3200 over this network
|
||||
|
||||
# --- Dashboards: Grafana ---
|
||||
|
||||
obs-grafana:
|
||||
image: grafana/grafana-oss:11.6.1
|
||||
container_name: obs-grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${PORT_GRAFANA:-3003}:3000"
|
||||
environment:
|
||||
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}
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./infra/observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health | grep -q ok || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
prometheus:
|
||||
condition: service_healthy
|
||||
loki:
|
||||
condition: service_healthy
|
||||
tempo:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- obs-net
|
||||
|
||||
# --- Error Tracking: GlitchTip ---
|
||||
|
||||
obs-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: obs-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- glitchtip_data:/data
|
||||
expose:
|
||||
- "6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- obs-net
|
||||
|
||||
obs-glitchtip:
|
||||
image: glitchtip/glitchtip:6.1.6
|
||||
container_name: obs-glitchtip
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
obs-redis:
|
||||
condition: service_healthy
|
||||
obs-glitchtip-db-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-archive-db}:5432/glitchtip
|
||||
REDIS_URL: redis://obs-redis:6379/0
|
||||
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
|
||||
GLITCHTIP_DOMAIN: ${GLITCHTIP_DOMAIN:-http://localhost:3002}
|
||||
DEFAULT_FROM_EMAIL: ${APP_MAIL_FROM:-noreply@familienarchiv.local}
|
||||
EMAIL_URL: smtp://mailpit:1025
|
||||
GLITCHTIP_MAX_EVENT_LIFE_DAYS: 90
|
||||
ports:
|
||||
- "127.0.0.1:${PORT_GLITCHTIP:-3002}:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "bash", "-c", "echo > /dev/tcp/localhost/8000"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
networks:
|
||||
- archiv-net
|
||||
- obs-net
|
||||
|
||||
obs-glitchtip-worker:
|
||||
image: glitchtip/glitchtip:6.1.6
|
||||
container_name: obs-glitchtip-worker
|
||||
restart: unless-stopped
|
||||
command: ./bin/run-celery-with-beat.sh
|
||||
depends_on:
|
||||
obs-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-archive-db}:5432/glitchtip
|
||||
REDIS_URL: redis://obs-redis:6379/0
|
||||
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
|
||||
networks:
|
||||
- archiv-net
|
||||
- obs-net
|
||||
|
||||
obs-glitchtip-db-init:
|
||||
image: postgres:16-alpine
|
||||
container_name: obs-glitchtip-db-init
|
||||
restart: "no"
|
||||
environment:
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
command: >
|
||||
sh -c "psql -h ${POSTGRES_HOST:-archive-db} -U ${POSTGRES_USER} -tc
|
||||
\"SELECT 1 FROM pg_database WHERE datname = 'glitchtip'\" |
|
||||
grep -q 1 ||
|
||||
psql -h ${POSTGRES_HOST:-archive-db} -U ${POSTGRES_USER} -c \"CREATE DATABASE glitchtip;\""
|
||||
networks:
|
||||
- archiv-net
|
||||
|
||||
networks:
|
||||
# Shared network created by the main docker-compose.yml.
|
||||
# The observability stack joins as a peer so Prometheus can scrape
|
||||
# archive-backend by container name. The observability stack must NOT
|
||||
# attempt to create this network — it will fail with a clear error if
|
||||
# the main stack is not running yet.
|
||||
archiv-net:
|
||||
external: true
|
||||
|
||||
# Internal network for observability-service-to-service traffic
|
||||
# (e.g. Grafana → Prometheus, Grafana → Loki, Grafana → Tempo).
|
||||
obs-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
loki_data:
|
||||
promtail_positions:
|
||||
tempo_data:
|
||||
grafana_data:
|
||||
glitchtip_data:
|
||||
@@ -26,20 +26,10 @@
|
||||
# MAIL_HOST, MAIL_PORT, SMTP relay (production only; staging uses mailpit)
|
||||
# MAIL_USERNAME, MAIL_PASSWORD
|
||||
# APP_MAIL_FROM sender address (e.g. noreply@raddatz.cloud)
|
||||
# IMPORT_HOST_DIR absolute host path holding ONLY the ODS
|
||||
# spreadsheet and PDFs for /admin/system mass
|
||||
# import — mounted read-only at /import inside
|
||||
# the backend. Compose refuses to start when
|
||||
# this var is unset, so staging and prod cannot
|
||||
# accidentally share an import source. Must be
|
||||
# readable by the backend container's UID
|
||||
# (currently root via the OpenJDK image — any
|
||||
# world-readable directory works).
|
||||
|
||||
networks:
|
||||
archiv-net:
|
||||
driver: bridge
|
||||
name: ${COMPOSE_NETWORK_NAME:-archiv-net}
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
@@ -183,12 +173,6 @@ services:
|
||||
# Bound to localhost only — Caddy fronts external traffic.
|
||||
ports:
|
||||
- "127.0.0.1:${PORT_BACKEND}:8080"
|
||||
# Host path holding the ODS spreadsheet + PDFs for the mass-import endpoint.
|
||||
# Read-only; MassImportService only reads (Files.list / Files.walk on /import).
|
||||
# Required — no default — so staging and prod cannot accidentally share an
|
||||
# import source. CI workflows pin this per-env (see .gitea/workflows/).
|
||||
volumes:
|
||||
- ${IMPORT_HOST_DIR:?Set IMPORT_HOST_DIR to a host path holding the mass-import payload (ODS + PDFs). See docs/DEPLOYMENT.md.}:/import:ro
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/archiv
|
||||
SPRING_DATASOURCE_USERNAME: archiv
|
||||
@@ -213,15 +197,10 @@ services:
|
||||
APP_MAIL_FROM: ${APP_MAIL_FROM:-noreply@raddatz.cloud}
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${MAIL_SMTP_AUTH:-true}
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-true}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318
|
||||
OTEL_LOGS_EXPORTER: none
|
||||
OTEL_METRICS_EXPORTER: none
|
||||
MANAGEMENT_METRICS_TAGS_APPLICATION: Familienarchiv
|
||||
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: ${MANAGEMENT_TRACING_SAMPLING_PROBABILITY:-0.1}
|
||||
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
|
||||
|
||||
@@ -147,20 +147,8 @@ services:
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
|
||||
APP_OCR_BASE_URL: http://ocr-service:8000
|
||||
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
||||
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0}
|
||||
# Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317)
|
||||
# Tempo is defined in docker-compose.observability.yml (future issue).
|
||||
# OTLP failures are non-fatal — backend starts cleanly without the observability stack.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
# 10% sampling in this compose (dev + staging) — override locally to 1.0 if needed
|
||||
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: "0.1"
|
||||
ports:
|
||||
- "${PORT_BACKEND}:8080"
|
||||
# Management port — Prometheus scrapes /actuator/prometheus from inside archiv-net.
|
||||
# Not exposed to the host; Docker service-name DNS (backend:8081) is sufficient.
|
||||
expose:
|
||||
- "8081"
|
||||
networks:
|
||||
- archiv-net
|
||||
healthcheck:
|
||||
|
||||
@@ -63,7 +63,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service |
|
||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
|
||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||
|
||||
@@ -43,7 +43,6 @@ graph TD
|
||||
- SSE notifications transit Caddy (browser → Caddy → backend); the backend is never reachable directly from the public internet. The SvelteKit SSR layer is bypassed for SSE, but Caddy is not.
|
||||
- The Caddyfile responds `404` on `/actuator/*` (defense in depth). Internal monitoring scrapes the backend on the docker network, not through Caddy.
|
||||
- Production and staging cohabit on the same host via docker compose project names: `archiv-production` (ports 8080/3000) and `archiv-staging` (ports 8081/3001).
|
||||
- An optional observability stack (Prometheus, Node Exporter, cAdvisor, Loki, Tempo, Grafana, GlitchTip) runs as a separate compose file. Configuration lives under `infra/observability/`. In production and CI, the stack is managed from `/opt/familienarchiv/` (CI copies it there on every nightly run) so bind mounts survive workspace wipes — see §4 for the ops procedure.
|
||||
|
||||
### OCR memory requirements
|
||||
|
||||
@@ -98,7 +97,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `APP_BASE_URL` | Public-facing URL for email links | `http://localhost:3000` | YES (prod) | — |
|
||||
| `APP_OCR_BASE_URL` | Internal URL of the OCR service | — | YES | — |
|
||||
| `APP_OCR_TRAINING_TOKEN` | Secret token for OCR training endpoints | — | YES (prod) | YES |
|
||||
| `IMPORT_HOST_DIR` | Absolute host path holding the ODS spreadsheet + PDFs for the `/admin/system` mass-import card. Mounted read-only at `/import` inside the backend (compose-only — backend reads via `app.import.dir`). Compose refuses to start when unset, so staging and prod cannot accidentally share the source. Convention: `/srv/familienarchiv-staging/import` and `/srv/familienarchiv-production/import` | — | YES (prod compose) | — |
|
||||
| `MAIL_HOST` | SMTP host | `mailpit` (dev) | YES (prod) | — |
|
||||
| `MAIL_PORT` | SMTP port | `1025` (dev) | YES (prod) | — |
|
||||
| `MAIL_USERNAME` | SMTP username | — | YES (prod) | YES |
|
||||
@@ -107,12 +105,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `MAIL_SMTP_AUTH` | SMTP auth enabled | `false` (dev) | YES (prod) | — |
|
||||
| `MAIL_STARTTLS_ENABLE` | STARTTLS enabled | `false` (dev) | YES (prod) | — |
|
||||
| `SPRING_PROFILES_ACTIVE` | Spring profile | `dev,e2e` (compose) | YES | — |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP HTTP endpoint for distributed traces (Tempo). Port 4318 = HTTP transport; port 4317 is gRPC-only and causes "Connection reset" with Spring Boot's HttpExporter. | `http://localhost:4318` | — | — |
|
||||
| `OTEL_LOGS_EXPORTER` | Disable OTLP log export — Promtail captures Docker logs via the logging driver; Tempo does not accept logs. | `none` | — | — |
|
||||
| `OTEL_METRICS_EXPORTER` | Disable OTLP metric export — Prometheus scrapes `/actuator/prometheus` via pull model; Tempo does not accept metrics. | `none` | — | — |
|
||||
| `MANAGEMENT_METRICS_TAGS_APPLICATION` | Common tag added to every Micrometer metric. Required by Grafana's Spring Boot Observability dashboard (ID 17175) `label_values(application)` template variable. | `Familienarchiv` | — | — |
|
||||
| `MANAGEMENT_TRACING_SAMPLING_PROBABILITY` | Micrometer tracing sample rate; overridden to `0.0` in test profile. | `0.1` (compose) / `1.0` (dev) | — | — |
|
||||
| `SENTRY_DSN` | GlitchTip / Sentry DSN for backend error reporting. Leave empty to disable the SDK. Set after GlitchTip first-run (§4). | — | — | YES |
|
||||
|
||||
### PostgreSQL container
|
||||
|
||||
@@ -141,19 +133,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
|
||||
| `OCR_MEM_LIMIT` | Container memory cap for ocr-service in `docker-compose.prod.yml`. Set to `6g` on CX32 hosts; leave unset on CX42+ to use the 12g default | `12g` (prod compose default) | — | — |
|
||||
|
||||
### Observability stack (`docker-compose.observability.yml`)
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
|---|---|---|---|---|
|
||||
| `PORT_PROMETHEUS` | Host port for the Prometheus UI (bound to `127.0.0.1` only) | `9090` | — | — |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `VITE_SENTRY_DSN` | GlitchTip/Sentry DSN for the frontend (SvelteKit) — injected at build time via Vite. Leave empty to disable. Set after GlitchTip first-run (§4). | — | — | YES |
|
||||
|
||||
---
|
||||
|
||||
## 3. Bootstrap from scratch
|
||||
@@ -199,29 +178,6 @@ curl -fsSL https://tailscale.com/install.sh | sh && tailscale up
|
||||
# files to disk during execution (cleaned up unconditionally on completion).
|
||||
# A multi-tenant runner would need to switch to stdin-piped env files.
|
||||
# (See https://docs.gitea.com/usage/actions/quickstart for the register step.)
|
||||
|
||||
# Runner workspace directory — required for DooD bind-mount resolution (ADR-015).
|
||||
# act_runner stores job workspaces here so that docker compose bind mounts resolve
|
||||
# to real host paths. The path must be identical on the host and inside job containers.
|
||||
mkdir -p /srv/gitea-workspace
|
||||
# Observability config permanent directory — the nightly CI job copies
|
||||
# docker-compose.observability.yml and infra/observability/ here on every run.
|
||||
# The obs stack is always started from this path, not from the workspace.
|
||||
# See ADR-016 for why this directory is used instead of a server-pull approach.
|
||||
mkdir -p /opt/familienarchiv/infra
|
||||
# Both paths must also appear in the runner service volumes in ~/docker/gitea/compose.yaml:
|
||||
# volumes:
|
||||
# - /srv/gitea-workspace:/srv/gitea-workspace
|
||||
# /opt/familienarchiv does NOT need to be in the runner container's volumes — job
|
||||
# containers are spawned by the host daemon directly (DooD), so the host path is
|
||||
# accessible to them as long as runner-config.yaml lists it in valid_volumes + options.
|
||||
# See runner-config.yaml (workdir_parent + valid_volumes + options) and ADR-015/016.
|
||||
|
||||
# ⚠ IMPORTANT: after any change to runner-config.yaml (valid_volumes, options, workdir_parent),
|
||||
# restart the Gitea Act runner for the new config to take effect:
|
||||
# docker restart gitea-runner
|
||||
# Until restarted, job containers are spawned with the old config and any new bind mounts
|
||||
# (e.g. /opt/familienarchiv) will not be available inside job steps.
|
||||
```
|
||||
|
||||
### 3.2 DNS records
|
||||
@@ -252,10 +208,6 @@ git.raddatz.cloud A <server IP>
|
||||
| `MAIL_PORT` | release.yml | typically `587` |
|
||||
| `MAIL_USERNAME` | release.yml | SMTP user |
|
||||
| `MAIL_PASSWORD` | release.yml | SMTP password |
|
||||
| `GRAFANA_ADMIN_PASSWORD` | both | Grafana `admin` login — generate a strong password |
|
||||
| `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 |
|
||||
|
||||
### 3.4 First deploy
|
||||
|
||||
@@ -283,9 +235,6 @@ Before the first deploy: rotate `PROD_APP_ADMIN_PASSWORD` to a strong value. Aft
|
||||
|
||||
## 4. Logs + observability
|
||||
|
||||
> **Developer guide (where to look for what, LogQL queries, trace exploration) → [docs/OBSERVABILITY.md](./OBSERVABILITY.md).**
|
||||
> This section covers the ops side: starting the stack, env vars, and CI wiring.
|
||||
|
||||
### First-response commands
|
||||
|
||||
```bash
|
||||
@@ -306,156 +255,9 @@ docker compose logs --tail=200 <service>
|
||||
- **Spring Actuator health**: `http://localhost:8080/actuator/health` (internal only in prod — port 8081 for Prometheus scraping)
|
||||
- **Prometheus scraping**: management port 8081, path `/actuator/prometheus`. Internal only; Caddy blocks `/actuator/*` externally.
|
||||
|
||||
### Observability stack
|
||||
### Future observability
|
||||
|
||||
An observability stack is available via `docker-compose.observability.yml`. Configuration lives under `infra/observability/`.
|
||||
|
||||
#### Dev — start from the workspace
|
||||
|
||||
```bash
|
||||
docker compose up -d # creates archiv-net
|
||||
docker compose -f docker-compose.observability.yml up -d
|
||||
```
|
||||
|
||||
#### Why the obs stack is managed differently from the main app stack
|
||||
|
||||
The main app stack (`docker-compose.prod.yml`) has no config-file bind mounts — its containers read config from env vars and image defaults. The workspace is wiped after each CI run but that does not affect running containers, because they hold no references to workspace paths.
|
||||
|
||||
The obs stack is different: `prometheus.yml`, `tempo.yml`, Loki config, Grafana provisioning files, and Promtail config are all bind-mounted from the host filesystem into their containers. If those source paths disappear (workspace wipe), the containers can restart fine until a `docker compose up` is run again — at that point Docker tries to re-resolve the bind-mount source and fails because the workspace path no longer exists.
|
||||
|
||||
The fix is to keep the obs compose file and config tree at a **permanent path** that CI copies to on every run but which survives between runs: `/opt/familienarchiv/` (see ADR-016).
|
||||
|
||||
#### Production — managed from `/opt/familienarchiv/`
|
||||
|
||||
Every CI run (nightly + release) copies `docker-compose.observability.yml` and `infra/observability/` to `/opt/familienarchiv/` before starting the stack. Bind mounts then resolve to `/opt/familienarchiv/infra/observability/…` — a stable path that outlasts any workspace wipe.
|
||||
|
||||
**Environment variables** follow the same two-source model as the main stack:
|
||||
|
||||
| Source | What it contains | Managed by |
|
||||
|---|---|---|
|
||||
| `infra/observability/obs.env` | All non-secret config (ports, URLs, hostnames) | Git — reviewed in PRs |
|
||||
| `/opt/familienarchiv/obs-secrets.env` | Passwords and secret keys only | CI — written fresh from Gitea secrets on every deploy |
|
||||
|
||||
Both files are passed explicitly via `--env-file` to the compose command, so there is no implicit auto-read `.env` and no operator-managed file to keep in sync.
|
||||
|
||||
**Non-secret config** (`infra/observability/obs.env`):
|
||||
|
||||
| Key | Value | Notes |
|
||||
|---|---|---|
|
||||
| `PORT_GRAFANA` | `3003` | Avoids collision with staging frontend on port 3001 |
|
||||
| `PORT_GLITCHTIP` | `3002` | |
|
||||
| `PORT_PROMETHEUS` | `9090` | |
|
||||
| `GF_SERVER_ROOT_URL` | `https://grafana.archiv.raddatz.cloud` | Required for alert email links and OAuth redirects |
|
||||
| `GLITCHTIP_DOMAIN` | `https://glitchtip.archiv.raddatz.cloud` | Must match the Caddy vhost |
|
||||
| `POSTGRES_HOST` | `archive-db` | Override if only the staging stack is running |
|
||||
|
||||
**Secret keys** (set in Gitea secrets, injected by CI into `obs-secrets.env`):
|
||||
|
||||
| Gitea secret | Notes |
|
||||
|---|---|
|
||||
| `GRAFANA_ADMIN_PASSWORD` | Strong unique password; shared by nightly and release |
|
||||
| `GLITCHTIP_SECRET_KEY` | `openssl rand -hex 32`; shared by nightly and release |
|
||||
| `STAGING_POSTGRES_PASSWORD` / `PROD_POSTGRES_PASSWORD` | Must match the running PostgreSQL container |
|
||||
|
||||
To start or restart the obs stack manually on the server (after CI has run at least once):
|
||||
|
||||
```bash
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
up -d --wait --remove-orphans
|
||||
```
|
||||
|
||||
> **Note (manual ops only):** CI clears the destination with `rm -rf` before copying, so deleted files are removed automatically on the next run. If you copy manually with `cp -r` without first removing the directory, stale files from deleted configs will persist until cleaned up:
|
||||
> ```bash
|
||||
> rm /opt/familienarchiv/infra/observability/<path-to-removed-file>
|
||||
> ```
|
||||
|
||||
Current services:
|
||||
|
||||
| Service | Image | Purpose |
|
||||
|---|---|---|
|
||||
| `obs-prometheus` | `prom/prometheus:v3.4.0` | Scrapes metrics from backend management port 8081 (`/actuator/prometheus`), node-exporter, and cAdvisor |
|
||||
| `obs-node-exporter` | `prom/node-exporter:v1.9.0` | Host-level CPU / memory / disk / network metrics |
|
||||
| `obs-cadvisor` | `gcr.io/cadvisor/cadvisor:v0.52.1` | Per-container resource metrics |
|
||||
| `obs-loki` | `grafana/loki:3.4.2` | Log aggregation — receives log streams from Promtail. Port 3100 is `expose`-only (not host-bound). |
|
||||
| `obs-promtail` | `grafana/promtail:3.4.2` | Log shipping agent — reads all Docker container logs via the Docker socket and forwards them to Loki with `container_name`, `compose_service`, `compose_project`, and `job` labels. The `job` label is mapped from the Docker Compose service name (`com.docker.compose.service`) so that Grafana Loki dashboard queries (`{job="backend"}`, `{job="frontend"}`) work out of the box and the "App" variable dropdown is populated. |
|
||||
| `obs-tempo` | `grafana/tempo:2.7.2` | Distributed trace storage — OTLP HTTP receiver on port 4318 (`archiv-net`-internal; backend sends traces here). Grafana queries traces on port 3200 (`obs-net`-internal). All ports are `expose`-only (not host-bound). |
|
||||
| `obs-grafana` | `grafana/grafana-oss:11.6.1` | Unified observability UI — metrics dashboards, log exploration, trace viewer. Bound to `127.0.0.1:${PORT_GRAFANA:-3003}` on the host. |
|
||||
| `obs-glitchtip` | `glitchtip/glitchtip:6.1.6` | Sentry-compatible error tracker. Receives frontend + backend error events, groups by fingerprint, provides issue UI with stack traces. Bound to `127.0.0.1:${PORT_GLITCHTIP:-3002}`. |
|
||||
| `obs-glitchtip-worker` | `glitchtip/glitchtip:6.1.6` | Celery + beat worker — processes async GlitchTip tasks (event ingestion, notifications, cleanup). |
|
||||
| `obs-redis` | `redis:7-alpine` | Celery task broker for GlitchTip. Internal to `obs-net`; no host port exposed. |
|
||||
| `obs-glitchtip-db-init` | `postgres:16-alpine` | One-shot init container. Creates the `glitchtip` database on the existing `archive-db` PostgreSQL instance if it does not already exist. Runs at stack startup; exits cleanly once done. |
|
||||
|
||||
#### Grafana
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| URL | `http://localhost:3003` (or `http://localhost:$PORT_GRAFANA`) |
|
||||
| Username | `admin` |
|
||||
| Password | `$GRAFANA_ADMIN_PASSWORD` (default: `changeme` — **change before exposing to a network**) |
|
||||
|
||||
Datasources are auto-provisioned on first start (Prometheus, Loki, Tempo — no manual setup required). Three dashboards are pre-loaded:
|
||||
|
||||
| Dashboard | Grafana ID | Purpose |
|
||||
|---|---|---|
|
||||
| Node Exporter Full | 1860 | Host CPU, memory, disk, network |
|
||||
| Spring Boot Observability | 17175 | JVM metrics, HTTP latency, error rate |
|
||||
| Loki Logs | 13639 | Log exploration and filtering |
|
||||
|
||||
Tempo traces are accessible via Grafana Explore → Tempo datasource, and linked from Loki logs via the `traceId` derived field.
|
||||
|
||||
**Loki quick checks** (after ~60 s, run from inside the `obs-loki` container):
|
||||
|
||||
```bash
|
||||
# Loki health
|
||||
docker exec obs-loki wget -qO- http://localhost:3100/ready
|
||||
|
||||
# List labels
|
||||
docker exec obs-loki wget -qO- 'http://localhost:3100/loki/api/v1/labels'
|
||||
|
||||
# Query logs by service (stable across dev and prod environments)
|
||||
docker exec obs-loki wget -qO- \
|
||||
'http://localhost:3100/loki/api/v1/query_range?query=%7Bcompose_service%3D%22backend%22%7D&limit=5'
|
||||
```
|
||||
|
||||
**Prefer `compose_service` over `container_name` in LogQL queries** — `container_name` differs between dev (`archive-backend`) and prod (`archiv-production-backend-1`), while `compose_service` is stable (`backend`, `db`, `minio`, etc.).
|
||||
|
||||
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.
|
||||
|
||||
#### GlitchTip
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| URL | `http://localhost:3002` (or `http://localhost:$PORT_GLITCHTIP`) |
|
||||
|
||||
**Required env vars** — set in `.env` before first start:
|
||||
|
||||
```bash
|
||||
GLITCHTIP_SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
GLITCHTIP_DOMAIN=http://localhost:3002 # change to your public URL in prod
|
||||
PORT_GLITCHTIP=3002 # optional, defaults to 3002
|
||||
```
|
||||
|
||||
**Database:** GlitchTip shares the existing `archive-db` PostgreSQL instance. The `obs-glitchtip-db-init` one-shot container creates a dedicated `glitchtip` database on first stack start — no manual step required.
|
||||
|
||||
**First-run steps** (one-time, after `docker compose -f docker-compose.observability.yml up -d`):
|
||||
|
||||
```bash
|
||||
# 1. Create the Django superuser (interactive)
|
||||
docker exec -it obs-glitchtip ./manage.py createsuperuser
|
||||
|
||||
# 2. Open the GlitchTip UI and log in
|
||||
open http://localhost:3002
|
||||
|
||||
# 3. Create an organisation (e.g. "Familienarchiv")
|
||||
# 4. Create two projects:
|
||||
# - "familienarchiv-frontend" (platform: JavaScript / SvelteKit)
|
||||
# - "familienarchiv-backend" (platform: Java / Spring Boot)
|
||||
# 5. Copy each project's DSN from Settings → Projects → <project> → Client Keys
|
||||
# 6. Wire the DSNs into the backend and frontend via env vars (separate issue)
|
||||
```
|
||||
Phase 7 of the Production v1 milestone adds Prometheus + Loki + Grafana. No monitoring infrastructure is in place yet.
|
||||
|
||||
---
|
||||
|
||||
@@ -530,18 +332,9 @@ bash scripts/download-kraken-models.sh
|
||||
|
||||
### Trigger a mass import (Excel/ODS)
|
||||
|
||||
**Dev:** drop the ODS spreadsheet + PDFs into `./import/` at the repo root — the dev compose bind-mounts it to `/import` automatically.
|
||||
|
||||
**Staging/production:**
|
||||
|
||||
1. Pre-stage the payload on the host. Convention: `/srv/familienarchiv-staging/import/` or `/srv/familienarchiv-production/import/`.
|
||||
```bash
|
||||
rsync -avh --progress ./import/ user@host:/srv/familienarchiv-staging/import/
|
||||
```
|
||||
2. Make sure `IMPORT_HOST_DIR=<host-path>` is set in `.env.staging` / `.env.production` (the nightly/release workflows already write this — see §3). Compose refuses to start without it.
|
||||
3. Redeploy the stack so the bind mount picks up — or, if the mount is already in place, skip to step 4.
|
||||
4. Call `POST /api/admin/trigger-import` (requires `ADMIN` permission), or click the "Import starten" button on `/admin/system`.
|
||||
5. The import runs asynchronously — poll `GET /api/admin/import-status`, watch `/admin/system`, or tail the backend logs.
|
||||
1. Place the import file in the `import/` bind mount on the backend container.
|
||||
2. Call `POST /api/admin/trigger-import` (requires `ADMIN` permission).
|
||||
3. The import runs asynchronously — poll `GET /api/admin/import-status` or watch backend logs.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
# Observability Guide
|
||||
|
||||
> **Ops reference (starting the stack, env vars, CI wiring) → [DEPLOYMENT.md §4](./DEPLOYMENT.md#4-logs--observability).**
|
||||
> This file is for developers: what signal lives where, how to reach it, and what to look for.
|
||||
|
||||
## Where to look for what
|
||||
|
||||
| I want to… | Go to |
|
||||
|---|---|
|
||||
| See the last N log lines from the backend | `docker compose logs --tail=100 backend` |
|
||||
| Search logs by keyword across time | Grafana → Explore → Loki |
|
||||
| Understand why an HTTP request failed | Grafana → Explore → Loki → filter by `traceId` → follow link to Tempo |
|
||||
| See a full distributed trace (DB queries, HTTP calls) | Grafana → Explore → Tempo → search by service or trace ID |
|
||||
| Check JVM heap / GC / thread count | Grafana → Dashboards → Spring Boot Observability |
|
||||
| Check HTTP error rate or p95 latency | Grafana → Dashboards → Spring Boot Observability |
|
||||
| Check host CPU / memory / disk | Grafana → Dashboards → Node Exporter Full |
|
||||
| See grouped application errors with stack traces | GlitchTip |
|
||||
| Check if the backend is healthy | `curl http://localhost:8081/actuator/health` (on the server) |
|
||||
| Check what Prometheus is scraping | `curl http://localhost:9090/api/v1/targets` (on the server) |
|
||||
|
||||
## Access
|
||||
|
||||
| Tool | External URL | Who it's for |
|
||||
|---|---|---|
|
||||
| Grafana | `https://grafana.archiv.raddatz.cloud` | Logs, metrics, traces — the primary observability UI |
|
||||
| GlitchTip | `https://glitchtip.archiv.raddatz.cloud` | Grouped errors with stack traces and release tracking |
|
||||
|
||||
Loki, Tempo, and Prometheus have no external URL. They are internal services, accessible only through Grafana (or via SSH tunnel — see below).
|
||||
|
||||
## Logs (Loki)
|
||||
|
||||
Logs reach Loki via Promtail, which reads all Docker container logs from the Docker socket and ships them with labels derived from Docker Compose metadata.
|
||||
|
||||
### Labels available in every log line
|
||||
|
||||
| Label | What it contains | Example |
|
||||
|---|---|---|
|
||||
| `job` | Compose service name | `backend`, `frontend`, `db` |
|
||||
| `compose_service` | Same as `job` | `backend` |
|
||||
| `compose_project` | Compose project name | `archiv-staging`, `archiv-production` |
|
||||
| `container_name` | Docker container name | `archiv-staging-backend-1` |
|
||||
| `filename` | Docker log source | `/var/lib/docker/containers/…` |
|
||||
|
||||
**Use `job` in LogQL queries** — it is stable across dev, staging, and production. `container_name` changes between environments.
|
||||
|
||||
### Common LogQL queries in Grafana Explore
|
||||
|
||||
```logql
|
||||
# All backend logs
|
||||
{job="backend"}
|
||||
|
||||
# Backend ERROR and WARN lines only
|
||||
{job="backend"} |= "ERROR" or {job="backend"} |= "WARN"
|
||||
|
||||
# All logs for a specific request (paste a traceId from a log line)
|
||||
{job="backend"} |= "3fa85f64-5717-4562-b3fc-2c963f66afa6"
|
||||
|
||||
# Log lines containing a specific exception class
|
||||
{job="backend"} |~ "DomainException|NullPointerException"
|
||||
|
||||
# Frontend logs
|
||||
{job="frontend"}
|
||||
|
||||
# Database (slow query log, if enabled)
|
||||
{job="db"}
|
||||
```
|
||||
|
||||
### Log → Trace correlation
|
||||
|
||||
Spring Boot writes the active `traceId` into every log line when a request is being processed:
|
||||
|
||||
```
|
||||
2026-05-16 ... INFO [Familienarchiv,3fa85f64...,1b2c3d4e] o.r.f.document.DocumentService : ...
|
||||
```
|
||||
|
||||
In Grafana Explore → Loki, log lines with a `traceId` field show a **Tempo** link. Clicking it opens the full trace in Explore → Tempo without copying and pasting IDs.
|
||||
|
||||
This linking is configured in the Loki datasource provisioning via the `traceId` derived field regex. No manual setup required.
|
||||
|
||||
## Traces (Tempo)
|
||||
|
||||
The backend sends traces to Tempo via OTLP HTTP (port 4318). Every inbound HTTP request and every JPA query produces a span. Spans are linked into traces by the propagated `traceId` header.
|
||||
|
||||
### Finding a trace in Grafana
|
||||
|
||||
**Option A — from a log line:**
|
||||
1. Grafana → Explore → select *Loki* datasource
|
||||
2. Query `{job="backend"}` and find the failing request
|
||||
3. Click the **Tempo** link in the log line (appears when `traceId` is present)
|
||||
|
||||
**Option B — by service:**
|
||||
1. Grafana → Explore → select *Tempo* datasource
|
||||
2. Query type: **Search**
|
||||
3. Service name: `familienarchiv-backend`
|
||||
4. Filter by HTTP status, duration, or operation name as needed
|
||||
|
||||
**Option C — by trace ID:**
|
||||
1. Grafana → Explore → select *Tempo* datasource
|
||||
2. Query type: **TraceQL** or **Trace ID**
|
||||
3. Paste the trace ID
|
||||
|
||||
### What each span type tells you
|
||||
|
||||
| Root span name pattern | What it covers |
|
||||
|---|---|
|
||||
| `GET /api/documents`, `POST /api/documents` | Full HTTP request lifecycle |
|
||||
| `SELECT archiv.*` | A single JPA/JDBC query inside that request |
|
||||
| `HikariPool.getConnection` | Connection pool wait time |
|
||||
|
||||
A slow `SELECT` span inside an otherwise fast HTTP span pinpoints a missing index. A slow `HikariPool.getConnection` span indicates connection pool exhaustion.
|
||||
|
||||
### Sampling rate
|
||||
|
||||
- **Dev**: 100% of requests are traced (`management.tracing.sampling.probability: 1.0` in `application.yaml`)
|
||||
- **Staging / Production**: 10% (`MANAGEMENT_TRACING_SAMPLING_PROBABILITY=0.1` in `docker-compose.prod.yml`)
|
||||
|
||||
To find a trace for a specific request in staging/production, either increase the sampling rate temporarily or trigger the request multiple times.
|
||||
|
||||
## Metrics (Prometheus → Grafana)
|
||||
|
||||
Prometheus scrapes the backend management endpoint every 15 s:
|
||||
|
||||
```
|
||||
Target: backend:8081/actuator/prometheus
|
||||
Labels: job="spring-boot", application="Familienarchiv"
|
||||
```
|
||||
|
||||
All Spring Boot metrics carry the `application="Familienarchiv"` tag, which is how the Grafana Spring Boot Observability dashboard (ID 17175) filters to this service.
|
||||
|
||||
### Useful Prometheus queries (run on the server or via Grafana Explore → Prometheus)
|
||||
|
||||
```promql
|
||||
# HTTP error rate (5xx) as a fraction of all requests
|
||||
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
|
||||
/ sum(rate(http_server_requests_seconds_count[5m]))
|
||||
|
||||
# p95 response time
|
||||
histogram_quantile(0.95, sum by (le) (
|
||||
rate(http_server_requests_seconds_bucket[5m])
|
||||
))
|
||||
|
||||
# JVM heap used
|
||||
jvm_memory_used_bytes{area="heap", application="Familienarchiv"}
|
||||
|
||||
# Active DB connections
|
||||
hikaricp_connections_active
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
GlitchTip complements Loki: use GlitchTip when you need **grouped, de-duplicated errors with stack traces and release attribution**; use Loki when you need **raw log lines with full context** or want to search across all log levels.
|
||||
|
||||
## Direct API access (debugging only)
|
||||
|
||||
Loki and Tempo bind no host ports. To reach them directly from your laptop, use an SSH tunnel through the server:
|
||||
|
||||
```bash
|
||||
# Loki API on localhost:3100 (then query via curl or logcli)
|
||||
ssh -L 3100:172.20.0.x:3100 root@raddatz.cloud
|
||||
# Replace 172.20.0.x with the obs-loki container IP:
|
||||
# docker inspect obs-loki --format '{{.NetworkSettings.Networks.archiv-obs-net.IPAddress}}'
|
||||
|
||||
# Tempo API on localhost:3200 (then query via curl or tempo-cli)
|
||||
ssh -L 3200:172.20.0.x:3200 root@raddatz.cloud
|
||||
```
|
||||
|
||||
In practice, Grafana Explore covers all common debugging workflows without needing direct API access.
|
||||
|
||||
## Signal summary
|
||||
|
||||
| Signal | Source | Transport | Storage | UI |
|
||||
|---|---|---|---|---|
|
||||
| Application logs | Spring Boot stdout → Docker log driver | Promtail → Loki push API | Loki | Grafana Explore → Loki |
|
||||
| Distributed traces | Spring Boot OTel agent | OTLP HTTP → Tempo:4318 | Tempo | Grafana Explore → Tempo |
|
||||
| JVM + HTTP metrics | Spring Actuator `/actuator/prometheus` | Prometheus pull (15 s) | Prometheus | Grafana dashboards |
|
||||
| Host metrics | node-exporter | Prometheus pull | Prometheus | Grafana → Node Exporter Full |
|
||||
| Container metrics | cAdvisor | Prometheus pull | Prometheus | Grafana (via Prometheus datasource) |
|
||||
| Application errors | Sentry SDK | HTTP POST → GlitchTip ingest | GlitchTip DB | GlitchTip UI |
|
||||
@@ -1,134 +0,0 @@
|
||||
# ADR 012 — Browser-Mode Test Mocking Strategy
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-11 (revised 2026-05-12)
|
||||
**Issues:** [#535 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/535) · [#553 — revision](https://git.raddatz.cloud/marcel/familienarchiv/issues/553)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Vitest browser-mode tests (the `client` project, run with `@vitest/browser-playwright` / Chromium) use a different module resolution path than Node-environment tests. When a spec calls `vi.mock('some-module', factory)`, vitest registers a `ManualMockedModule`. At runtime, every time Chromium requests that module, a playwright route handler intercepts the request and calls the Node worker over **birpc** (`resolveManualMock`) to evaluate the factory and return the module body.
|
||||
|
||||
This is safe for modules that are imported **statically** at spec module-eval time (e.g. `$app/navigation`, `$env/static/public`): those requests resolve before the first test runs and well before any teardown occurs.
|
||||
|
||||
It is **unsafe** for modules that are imported **dynamically** (e.g. inside an `async onMount`, inside a lazy-loaded chunk): Chromium may fetch the module after the worker's birpc channel has already closed, producing:
|
||||
|
||||
```
|
||||
Error: [birpc] rpc is closed, cannot call "resolveManualMock"
|
||||
❯ ManualMockedModule.factory node_modules/@vitest/browser/dist/index.js:3221:34
|
||||
```
|
||||
|
||||
This raises an unhandled rejection that exits the vitest process with code 1, even though every test in the run reported green.
|
||||
|
||||
`pdfjs-dist` and `pdfjs-dist/build/pdf.worker.min.mjs?url` are loaded via `await Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')])` inside `usePdfRenderer.svelte.ts::init()`, which is called from `onMount`. These dynamic imports triggered the race.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
**Prefer prop injection over `vi.mock(module, factory)` for any module that is loaded dynamically in browser-mode specs.**
|
||||
|
||||
### The libLoader pattern (for external rendering libraries)
|
||||
|
||||
When a component depends on a large external library loaded via dynamic import, extract the import into an injectable loader function with a production default:
|
||||
|
||||
```typescript
|
||||
// usePdfRenderer.svelte.ts
|
||||
type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>;
|
||||
|
||||
const defaultLibLoader: LibLoader = () =>
|
||||
Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]);
|
||||
|
||||
export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { ... }
|
||||
```
|
||||
|
||||
The component threads the loader as an optional prop:
|
||||
|
||||
```svelte
|
||||
<!-- PdfViewer.svelte -->
|
||||
let { url, ..., libLoader = undefined } = $props();
|
||||
const renderer = untrack(() => createPdfRenderer(libLoader));
|
||||
```
|
||||
|
||||
Tests supply a synchronous fake — no `vi.mock` needed:
|
||||
|
||||
```typescript
|
||||
const fakePdfjs = { GlobalWorkerOptions: ..., getDocument: vi.fn(), TextLayer: class {} };
|
||||
const fakeLoader = vi.fn().mockResolvedValue([fakePdfjs, { default: '' }] as const);
|
||||
render(PdfViewer, { url: '...', libLoader: fakeLoader });
|
||||
```
|
||||
|
||||
### The test-host pattern (for component behaviour)
|
||||
|
||||
For components that fetch data or call services, the `*.test-host.svelte` pattern threads the dependency as a prop rather than mocking the module. See `PersonMentionEditor.test-host.svelte` for the canonical example.
|
||||
|
||||
---
|
||||
|
||||
## Binding invariant: factory bodies must be synchronous (#553)
|
||||
|
||||
The original revision of this ADR allowed `vi.mock(virtualModule, factory)` for SvelteKit/Vite virtual modules on the argument that their consumer imports were resolved at static-import time. **That reasoning is wrong.** What matters is what the **factory body** does, not where the mocked module is consumed.
|
||||
|
||||
`EnrichmentBlock.svelte.spec.ts` (issue #553) was statically imported and still produced the race: its `vi.mock('$app/stores', async () => { const mod = await import(...); return mod; })` factory performed a dynamic import in its body, and that body was invoked asynchronously when Chromium fetched the manually-mocked module — sometimes after the worker's birpc channel had already closed.
|
||||
|
||||
**Therefore: under `**/*.svelte.{test,spec}.ts`, every `vi.mock` factory body must be synchronous. No `await`, no `import(...)`.**
|
||||
|
||||
If a factory needs to share state with the spec (a mutable ref, a `vi.fn`, a writable store), use `vi.hoisted()` to lift the reference above `vi.mock`'s implicit hoist:
|
||||
|
||||
```ts
|
||||
const { mockNavigating } = vi.hoisted(() => ({
|
||||
mockNavigating: { type: null as string | null }
|
||||
}));
|
||||
|
||||
vi.mock('$app/state', () => ({
|
||||
get navigating() {
|
||||
return mockNavigating;
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
The getter defers the read until consumption time; `vi.hoisted` guarantees the reference is initialised before the (also hoisted) `vi.mock` factory runs. See `DropZone.svelte.spec.ts:9`, `NotificationBell.svelte.spec.ts:6-10`, and `EnrichmentBlock.svelte.spec.ts` for canonical examples.
|
||||
|
||||
### Architectural follow-on: prefer `$app/state` over `$app/stores`
|
||||
|
||||
`$app/stores` is the deprecated subscription-based store API; `$app/state` is the modern reactive proxy. New components should import from `$app/state`. As part of #553 we migrated `EnrichmentBlock.svelte` from `$app/stores.navigating` to `$app/state.navigating` with `!!navigating.type` — matching the pattern already established in `routes/aktivitaeten/+page.svelte:117` and `routes/documents/+page.svelte:261`. Migration eliminated the *need* to mock a store at all in that spec.
|
||||
|
||||
**Pattern note:** When an overlay or dropdown triggers a navigation action, use `<button type="button">` with an `onclick` handler that calls `goto(path)` — do **not** use `<a href="…">` with `e.preventDefault()`. SvelteKit registers its link interceptor as a capture-phase `document` listener, so it fires before the component's bubble-phase `onclick`. By the time `e.preventDefault()` runs the router has already initiated navigation, which tears down the vitest-browser Playwright orchestrator iframe. A `<button>` carries no `href`, so the capture-phase interceptor never fires. See `NotificationDropdown.svelte` for the canonical example.
|
||||
|
||||
**Pattern note (#553):** Browser-mode tests run with `data-sveltekit-preload-data="off"` (set in `src/test-setup.ts` via the client project's `setupFiles`). Hover-prefetch otherwise fires real fetch requests for route loader chunks; those requests go through the same Playwright route handler that serves mocked modules. An in-flight prefetch landing after iframe teardown can hit the handler with a closed birpc channel, raising an unhandled rejection.
|
||||
|
||||
---
|
||||
|
||||
## Binding invariant: one canonical ID per mocked module (#553 — duplicate-id hazard)
|
||||
|
||||
The sync-factory invariant above closes one named trigger of the `[birpc] rpc is closed` race. Investigation of a follow-up flake revealed a second, independent trigger: **the same resolved module URL mocked under two distinct ID strings** across or within spec files.
|
||||
|
||||
`@vitest/browser-playwright` registers a Playwright `page.context().route(...)` handler per `vi.mock` call. The predicate matches on the module's resolved URL. When two `vi.mock` calls reference the same module under different IDs — for example `'$lib/foo.svelte'` and `'$lib/foo.svelte.js'` (both resolve to the same Svelte rune-module URL) — the registry stores both predicates but the cleanup map only tracks the latest. The orphan route survives session teardown. When the next session loads the same module, the orphan fires, calls `await module.resolve()` against a closed birpc channel, and crashes the run.
|
||||
|
||||
This is fixed upstream in [vitest PR #10267](https://github.com/vitest-dev/vitest/pull/10267) (issue [#9957](https://github.com/vitest-dev/vitest/issues/9957)). Until that fix reaches a published `@vitest/browser-playwright` release, we close the gap from two sides:
|
||||
|
||||
**The rule.** Every mocked module must be referenced under exactly one ID string across the entire client test suite. Pick the spelling production code uses. For Svelte 5 rune modules (`*.svelte.ts`), the canonical form is the no-extension import (`'$lib/foo.svelte'`) — matches the source file basename and matches Svelte 5 convention. Never mix `.svelte.js` and `.svelte` for the same module across specs.
|
||||
|
||||
**Enforcement layers** (added in #553's second cycle, extending the four-layer chain above):
|
||||
|
||||
5. **In-suite meta-test** at `frontend/src/__meta__/no-duplicate-mock-ids.test.ts` globs `src/**/*.svelte.{test,spec}.ts`, extracts every `vi.mock` first-arg string, canonicalises by stripping a trailing `.js`/`.ts` after `.svelte`, and fails if any canonical ID is referenced under two or more distinct spellings. Same shape as `no-async-mock-factories.test.ts`.
|
||||
6. **`patch-package` backport** of PR #10267 at `frontend/patches/@vitest+browser-playwright+4.1.0.patch`. Applied automatically by the `postinstall` hook. Closes the race at the route-handler level — even if a contributor reintroduces a duplicate-ID, the patched `register` handler unroutes the existing predicate before installing the new one.
|
||||
|
||||
**When to remove the patch.** Once `@vitest/browser-playwright` ships a release containing PR #10267, delete `patches/@vitest+browser-playwright+4.1.0.patch`. Bump the dependency to the version containing the fix. The in-suite meta-test stays — it's a cheap permanent guard against the contributor-facing pattern, independent of upstream library version.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
- New browser-mode specs that need to stub an external library **must not** use `vi.mock(externalLib, factory)`. Add a loader/factory parameter to the underlying hook or service instead.
|
||||
- The CI `unit-tests` job includes a permanent grep guard that fails the build if `rpc is closed` appears in any coverage run log. This catches regressions before they reach the acceptance criterion.
|
||||
- Acceptance criterion for #535: 60 consecutive green `workflow_dispatch` CI runs against `main` after the fix is merged, with zero `rpc is closed` lines in any log.
|
||||
- **Enforcement (six layers, defence in depth):**
|
||||
1. **ESLint `no-restricted-syntax`** in `eslint.config.js` (scoped to `**/*.{spec,test}.ts`) flags two patterns: (a) the literal `vi.mock('pdfjs-dist', ...)` — enforces the libLoader pattern — and (b) any `vi.mock(..., async () => { ... await import(...) ... })` — enforces the synchronous-factory invariant. Both messages point at this ADR. Failure surfaces at save time.
|
||||
2. **CI grep guard** in `.gitea/workflows/ci.yml` runs before the test suite launches. Mirrors the ESLint patterns with `grep -Pzn`. ~10s round-trip.
|
||||
3. **In-suite meta-test** at `frontend/src/__meta__/no-async-mock-factories.test.ts` globs `src/**/*.svelte.{test,spec}.ts` and asserts none match the banned pattern. Catches at every vitest invocation — the layer hardest to disable.
|
||||
4. **CI birpc assert** runs after the coverage step and fails the build if `[birpc] rpc is closed` appears in any log line. Catches the symptom even if all the upstream layers were bypassed.
|
||||
5. **In-suite duplicate-ID meta-test** at `frontend/src/__meta__/no-duplicate-mock-ids.test.ts` enforces the one-canonical-ID-per-module rule from the duplicate-id-hazard section above.
|
||||
6. **`patch-package` backport** at `frontend/patches/@vitest+browser-playwright+4.1.0.patch` closes the upstream race itself, applied via `postinstall`. To be removed when `@vitest/browser-playwright` releases [vitest PR #10267](https://github.com/vitest-dev/vitest/pull/10267).
|
||||
- **Acceptance verification:** `coverage-flake-probe.yml` is a `workflow_dispatch`-triggered matrix workflow that runs the coverage suite 20× in parallel against a single SHA and asserts zero birpc lines. One fire, parallel cost, deterministic signal — replaces accumulating 20 sequential push events.
|
||||
- **When to revisit the LibLoader home:** If three or more components adopt this pattern, consider extracting a shared `$lib/types/lib-loader.ts` or a generic `DynamicImportLoader<T>` type to avoid parallel type definitions across modules.
|
||||
@@ -1,92 +0,0 @@
|
||||
# ADR 013 — Client-Project Branch Coverage Threshold
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-14
|
||||
**Issues:** [#556 — threshold drop](https://git.raddatz.cloud/marcel/familienarchiv/issues/556) · [#496 — long-tail-grind tracking](https://git.raddatz.cloud/marcel/familienarchiv/issues/496)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The browser-mode component test suite (`vitest.client-coverage.config.ts`) enforces Istanbul coverage thresholds across `lines`, `functions`, `branches`, and `statements`. The `branches` metric was set to 80%, but the codebase sits at **75%** — below the gate — causing every CI run of `unit-tests` and `coverage-flake-probe` to fail on this check alone, even when all tests are green.
|
||||
|
||||
**Measured baseline (2026-05-14, branch `feat/issue-553-birpc-async-mock-factory`, head `2e6cc346`):**
|
||||
|
||||
```
|
||||
branches: 75% (below the 80% gate — reason for this ADR)
|
||||
lines: ≥ 80%
|
||||
functions: ≥ 80%
|
||||
statements: ≥ 80%
|
||||
```
|
||||
|
||||
Reproducer:
|
||||
|
||||
```bash
|
||||
cd frontend && npm ci && npx vitest run -c vitest.client-coverage.config.ts --coverage
|
||||
```
|
||||
|
||||
### The long-tail-grind problem
|
||||
|
||||
In Istanbul's branch accounting, when a child component gains test coverage its branches are added to the parent's denominator. A child moving from 40% → 80% coverage can drag a parent from 78% → 72% because more branches in the call graph become reachable and must be covered. This is not a bug — it is how branch accounting works — but it means that on a large SvelteKit application the denominator grows with every coverage improvement, making an arbitrary 80% ceiling a constant grind. Per #496, the expected cost to reach 80% branches from 75% is 30–100+ commits with no guarantee of stability.
|
||||
|
||||
### Why this layer is different
|
||||
|
||||
The 80% branch floor used for backend unit/integration tests is appropriate for Java service code and permission logic. Browser-mode component coverage measures Svelte template branches: conditional class bindings, `{#if}` blocks, empty/loaded/error state guards. These branches have a fundamentally different accounting model and a higher inherent denominator. This ADR **only** lowers the browser-mode component gate; the backend test coverage gates are unaffected.
|
||||
|
||||
### Security-relevant uncovered components
|
||||
|
||||
The following auth/permission-boundary components currently have low or zero branch coverage. When ratchet-up work begins (see below), these are the highest-priority targets:
|
||||
|
||||
- `src/routes/login/+page.svelte`
|
||||
- `src/routes/forgot-password/+page.svelte`
|
||||
- `src/routes/reset-password/+page.svelte`
|
||||
- `src/routes/register/+page.svelte`
|
||||
|
||||
Note: the 75% figure already reflects the absence of coverage on these files. Lowering the gate does not create this gap — it makes the existing state legible.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Drop the `branches` threshold from `80` → `75` in `frontend/vitest.client-coverage.config.ts`. Leave `lines`, `functions`, and `statements` at `80`.
|
||||
|
||||
The 75% figure matches the measured current state, allowing CI to pass while deliberate coverage improvement work (tracked in #496) continues without blocking other PRs. The asymmetry in the thresholds block is intentional and documented with an inline comment pointing here.
|
||||
|
||||
---
|
||||
|
||||
## Ratchet Rule
|
||||
|
||||
The branches threshold ratchets **up by 3 percentage points** when the rolling 3-PR-average client-project branches figure on `main` stays at or above `threshold + 3pp` for ≥ 30 consecutive days. Direction is **up-only** — never lower the floor below 75 without a new ADR superseding this one. Manual today (verify before any `vitest.client-coverage.config.ts` edit); a future automation issue may codify the check.
|
||||
|
||||
Concretely:
|
||||
- When `main` sustains ≥ 78% branches across 3 consecutive PRs for 30 days → raise gate to 78%
|
||||
- When `main` sustains ≥ 81% branches across 3 consecutive PRs for 30 days → raise gate back to 80%
|
||||
|
||||
---
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Not** raising actual branch coverage — that is #496's job, tracked separately.
|
||||
- **Not** touching the server-project coverage configuration (`vitest.config.ts`) — only the client project hits the long-tail-grind pattern.
|
||||
- **Not** removing or relaxing any existing test files, `skipIf` guards, or axe-playwright accessibility runs.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:**
|
||||
- CI unblocked — `unit-tests` and `coverage-flake-probe` jobs pass when all tests are green
|
||||
- The ratchet rule creates a concrete, observable path back to 80%
|
||||
|
||||
**Harder:**
|
||||
- The gate now has near-zero headroom — any branch regression that drops below 75% will fail CI immediately
|
||||
- The 75% floor must not be treated as a permanent ceiling; the ratchet discipline requires active attention
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [#496 — Branch coverage long-tail grind](https://git.raddatz.cloud/marcel/familienarchiv/issues/496)
|
||||
- [#556 — This threshold drop](https://git.raddatz.cloud/marcel/familienarchiv/issues/556)
|
||||
- [ADR 012 — Browser-Mode Test Mocking Strategy](./012-browser-test-mocking-strategy.md)
|
||||
- `frontend/vitest.client-coverage.config.ts` — thresholds block (lines 44–51)
|
||||
@@ -1,122 +0,0 @@
|
||||
# ADR 014 — Pin actions/upload-artifact to v3 (Gitea act_runner v4 protocol incompatibility)
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-14
|
||||
**Issues:** [#557 — re-regression](https://git.raddatz.cloud/marcel/familienarchiv/issues/557) · [#14 — original incident](https://git.raddatz.cloud/marcel/familienarchiv/issues/14)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
`actions/upload-artifact` is available in two incompatible major versions. The v4 client
|
||||
uploads via a GitHub-specific artifact API that is **not implemented** in Gitea's
|
||||
`act_runner` (the self-hosted CI substrate established by ADR-011). When a workflow step
|
||||
uses `actions/upload-artifact@v4` on this runner, `act_runner` returns a non-zero exit
|
||||
code from the v4 client even when all tests pass, producing:
|
||||
|
||||
> green test suite — red job status — no artifact uploaded
|
||||
|
||||
The failure lands in the upload step, _after_ the test output, making it hard to diagnose
|
||||
from the build log.
|
||||
|
||||
### Incident history
|
||||
|
||||
| Date | Commit | Event |
|
||||
|---|---|---|
|
||||
| 2026-03-19 | `9f3f022e` | Original downgrade: `upload-artifact@v4 → v3` |
|
||||
| 2026-03-19 | `4142c7cd` | Rationale committed; closes #14 |
|
||||
| 2026-05-05 | `410b91e2` | Re-regression: upgraded back to v4 without referencing #14 |
|
||||
| 2026-05-14 | this PR | Second downgrade + ADR + grep guard |
|
||||
|
||||
The root cause of the re-regression was institutional-memory failure: the original
|
||||
rationale was captured only in a commit body, invisible at the point of change (the
|
||||
`uses:` line). This ADR, the inline comments, and the grep guard are the three
|
||||
defence layers that replace that missing breadcrumb.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
**Pin all `actions/upload-artifact` and `actions/download-artifact` call sites to `@v3`.**
|
||||
|
||||
Both action families share the same v4 protocol incompatibility with `act_runner`.
|
||||
Pinning to the major tag (`@v3`) keeps us on the latest v3 patch without Renovate noise.
|
||||
|
||||
Three call sites are pinned:
|
||||
- `.gitea/workflows/ci.yml` — "Upload coverage reports" step
|
||||
- `.gitea/workflows/ci.yml` — "Upload screenshots" step
|
||||
- `.gitea/workflows/coverage-flake-probe.yml` — "Upload coverage log on failure" step
|
||||
|
||||
Each pinned `uses:` line carries a load-bearing inline comment:
|
||||
|
||||
```yaml
|
||||
# Gitea Actions (act_runner) does not implement upload-artifact v4 protocol — pinned per ADR-014. Do NOT upgrade. See #557.
|
||||
- uses: actions/upload-artifact@v3
|
||||
```
|
||||
|
||||
A CI grep guard enforces the constraint automatically (see below).
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Enforcement layers (defence in depth)
|
||||
|
||||
1. **Inline comments** on every `uses:` line — visible at the point of change.
|
||||
2. **CI grep guard** in `.gitea/workflows/ci.yml` ("Assert no (upload|download)-artifact
|
||||
past v3") — fails the build if a future commit re-introduces `@v4` or higher on any
|
||||
workflow file. Anchored to YAML `uses:` lines to avoid false positives on embedded
|
||||
shell strings. Includes a self-test that proves the regex catches v4+ before scanning
|
||||
the repo.
|
||||
3. **This ADR** — canonical rationale; cross-referenced by comments and guard message.
|
||||
|
||||
### How to spot the symptom
|
||||
|
||||
- Test suite output shows green (vitest, surefire, pytest all exit 0)
|
||||
- CI job status shows red
|
||||
- Artifacts section of the run is empty
|
||||
- Build log shows a non-zero exit from the `Upload …` step immediately after green tests
|
||||
|
||||
### `@v3` maintenance-mode status
|
||||
|
||||
GitHub placed `actions/upload-artifact@v3` in maintenance mode (no new features) but it
|
||||
has not been removed and carries no known unpatched CVE as of this writing. If GitHub
|
||||
publishes a v3-specific security advisory, that is an additional trigger to re-evaluate
|
||||
(see upgrade conditions below).
|
||||
|
||||
### When to remove this pin
|
||||
|
||||
Re-evaluate pinning **when either condition is met:**
|
||||
|
||||
1. `gitea/act_runner` ships a release with v4 artifact protocol support. Track upstream:
|
||||
<https://gitea.com/gitea/act_runner>
|
||||
2. `actions/upload-artifact@v3` acquires an unpatched CVE that cannot be mitigated
|
||||
at the runner level.
|
||||
|
||||
When upgrading: remove the grep guard step, update all three `uses:` lines, remove the
|
||||
inline comments, and update this ADR's status to Superseded.
|
||||
|
||||
---
|
||||
|
||||
## Alternatives
|
||||
|
||||
### SHA pinning (`uses: actions/upload-artifact@<sha>`)
|
||||
|
||||
More secure against action repository compromise, but adds Renovate update friction
|
||||
and is disproportionate for a self-hosted, single-tenant Gitea instance with one
|
||||
trusted contributor (ADR-011). Rejected.
|
||||
|
||||
### Minor/patch pinning (`@v3.4.0`)
|
||||
|
||||
Avoids Renovate PRs but freezes us on a specific patch. The v3 major track is in
|
||||
maintenance mode — minor pinning has no benefit and would require manual updates
|
||||
for any v3 security patches. Rejected.
|
||||
|
||||
### Renovate `packageRules` bypass
|
||||
|
||||
Would prevent automated PRs from proposing v4. Not needed while Renovate is not
|
||||
configured for this repository. Revisit if Renovate is introduced.
|
||||
|
||||
### Migrating the runner to a v4-compatible Gitea release
|
||||
|
||||
Out of scope for this issue. A separate decision; tracked in #557's non-goals.
|
||||
@@ -1,69 +0,0 @@
|
||||
# ADR-015: DooD workspace bind mount for Compose file bind-mount resolution
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The deploy workflows (`.gitea/workflows/nightly.yml`, `release.yml`) run job steps inside Docker containers via Docker-out-of-Docker (DooD): the Gitea runner mounts the host Docker socket, and act_runner spawns sibling containers for each job.
|
||||
|
||||
When a job step calls `docker compose -f docker-compose.observability.yml up`, Docker Compose resolves relative bind-mount sources against `$(pwd)` inside the job container and passes the resulting absolute paths to the **host** daemon. For example, `./infra/observability/prometheus/prometheus.yml` becomes `/some/path/infra/observability/prometheus/prometheus.yml`, and the host daemon tries to bind-mount that path from the **host filesystem**.
|
||||
|
||||
In the default DooD setup (`runner-config.yaml` with only `valid_volumes: ["/var/run/docker.sock"]`), job container workspaces live in the act_runner overlay2 layer. The host has no corresponding directory at the job container's `$(pwd)` path, so the daemon auto-creates an empty directory in its place. The container then fails to start because the mount target was expected to be a file, not a directory:
|
||||
|
||||
```
|
||||
error mounting "…/prometheus/prometheus.yml" to rootfs at "/etc/prometheus/prometheus.yml": not a directory
|
||||
```
|
||||
|
||||
This affected all five config file bind mounts in `docker-compose.observability.yml`.
|
||||
|
||||
## Decision
|
||||
|
||||
Configure act_runner to store job workspaces on a real host path (`/srv/gitea-workspace`) and mount that path into both the runner container and every job container at the **same absolute path**. The identity of the host path and container path is the key constraint: Compose resolves to an absolute path and hands it to the host daemon, which looks for that exact path on the host filesystem.
|
||||
|
||||
**runner-config.yaml changes:**
|
||||
|
||||
```yaml
|
||||
container:
|
||||
workdir_parent: /srv/gitea-workspace
|
||||
valid_volumes:
|
||||
- "/var/run/docker.sock"
|
||||
- "/srv/gitea-workspace"
|
||||
options: "-v /srv/gitea-workspace:/srv/gitea-workspace"
|
||||
```
|
||||
|
||||
**Runner compose.yaml change** (host side — not in this repo):
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
volumes:
|
||||
- /srv/gitea-workspace:/srv/gitea-workspace
|
||||
```
|
||||
|
||||
With this in place, `$(pwd)` inside a job container resolves to `/srv/gitea-workspace/<owner>/<repo>/`, which is a real directory on the host. Compose-managed bind mounts from that directory work without any additional steps.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Alternative | Why rejected |
|
||||
|---|---|
|
||||
| **overlay2 `MergedDir` sync via privileged nsenter** (the previous approach, see PR #599 v1) | Required `--privileged --pid=host` (effective root on the host) plus fragile overlay2 driver assumption. Introduced stale-file risk on the host and a second stable path (`/srv/familienarchiv-*/obs-configs`) to maintain separately from the source tree. Replaced by this ADR. |
|
||||
| **Build configs into a dedicated Docker image** (pattern used for MinIO bootstrap, see `infra/minio/Dockerfile`) | Viable for static files that change infrequently. Requires a build step and an image rebuild every time a config changes. Appropriate for bootstrap scripts; too heavy for frequently-tuned observability configs. |
|
||||
| **Add workspace directory to runner-config `valid_volumes` only** (without `workdir_parent`) | `valid_volumes` whitelists paths that workflow steps may reference, but does not change where act_runner stores workspaces. Without `workdir_parent`, the workspace would still be in overlay2 and the bind-mount resolution problem would remain. |
|
||||
| **Map workspace under a different host path than container path** (e.g. host `/srv/workspace`, container `/workspace`) | Compose resolves to the container-internal path (e.g. `/workspace/…`) and passes that to the host daemon. The host daemon interprets the source as a host path. If host `/workspace` does not exist, the daemon creates an empty directory — the original bug. The paths must be identical. |
|
||||
|
||||
## Consequences
|
||||
|
||||
- `/srv/gitea-workspace` must exist on the VPS before the runner starts. The directory was created as part of this change; it is not created automatically.
|
||||
- The runner container's `compose.yaml` (maintained outside this repo at `~/docker/gitea/compose.yaml` on the VPS) must include the `- /srv/gitea-workspace:/srv/gitea-workspace` volume line. This is an out-of-band operational dependency; the prerequisite is documented in `runner-config.yaml`.
|
||||
- `workdir_parent` applies to all jobs on this runner. Any future workflow that calls `docker compose` with relative bind mounts benefits automatically without further configuration.
|
||||
- Job workspaces persist across runs under `/srv/gitea-workspace`. act_runner manages per-run subdirectory cleanup. Orphaned directories from interrupted runs should be cleaned up manually if disk space becomes a concern.
|
||||
- Workflows that previously relied on `OBS_CONFIG_DIR` env var or the `obs-configs` stable path on the host no longer need those. Both were removed in this PR.
|
||||
- This pattern does **not** apply to the `nsenter`-based Caddy reload step (ADR-012), which manages a host systemd service — a different problem class with no bind-mount equivalent.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-011 — single-tenant runner trust model
|
||||
- ADR-012 — nsenter via privileged container for host service management
|
||||
- Issue #598 — original observability stack bind-mount failure
|
||||
- `runner-config.yaml` — `workdir_parent`, `valid_volumes`, `options`
|
||||
@@ -1,57 +0,0 @@
|
||||
# ADR-016: Observability stack co-location at `/opt/familienarchiv/` with CI-push config sync
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Issue #601 established that the observability stack must survive Gitea CI workspace wipes between nightly runs. When the nightly job completes, act_runner deletes the job workspace. Any Docker container that bind-mounts a config file from a workspace path (`/srv/gitea-workspace/…/infra/observability/prometheus/prometheus.yml`) then references a path that no longer exists on the host. On the next nightly run, Docker Compose either auto-creates an empty directory in its place (causing the container to fail to start because a file mount receives a directory) or finds a stale file from a previous run if the workspace happened to land at the same path.
|
||||
|
||||
ADR-015 solved the workspace bind-mount resolution problem: job workspaces are stored at `/srv/gitea-workspace` so `$(pwd)` inside the job container maps to a real host path. But it did not address persistence: the workspace is still wiped after the job, so bind mounts from workspace-relative paths remain fragile across runs.
|
||||
|
||||
### Decision drivers
|
||||
|
||||
1. Bind-mount sources must point to a host path that persists indefinitely, not to a path that disappears after each CI run.
|
||||
2. Config files must reflect the committed state of the repo after every nightly run (no manual sync steps).
|
||||
3. Secrets must not be written to the workspace or to any path managed by CI; they must survive independently of deployments.
|
||||
4. The solution must not introduce new infrastructure dependencies (no SSH access from CI, no external registry, no additional server-side daemon).
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
**A: Server-pull model** — a systemd timer or cron job on the server does `git pull` from the repo into `/opt/familienarchiv/` and then runs `docker compose up`. Rejected because: (1) requires git credentials on the server and a registered deploy key, (2) adds a second deployment mechanism that diverges from the CI-push model used for the main app stack, (3) timing coupling — the server pull must complete before CI's health checks run, requiring polling or a webhook.
|
||||
|
||||
**B: Separate directory (e.g. `/opt/obs/`)** — keeps obs configs isolated from the app stack. Rejected because: (1) the main app compose files are already in `/opt/familienarchiv/` (managed the same way), and (2) GlitchTip shares the `archive-db` PostgreSQL instance and `archiv-net` Docker network — it is architecturally part of the same deployment unit, not a separate one. Co-location reflects the actual coupling.
|
||||
|
||||
**C: Named Docker configs (Swarm)** — Docker Swarm supports first-class config objects that persist in the cluster. Rejected because the project does not use Swarm and introducing it solely for config persistence is a disproportionate dependency.
|
||||
|
||||
## Decision
|
||||
|
||||
The observability stack is co-located with the main application deployment at `/opt/familienarchiv/`:
|
||||
|
||||
- `docker-compose.observability.yml` → `/opt/familienarchiv/docker-compose.observability.yml`
|
||||
- `infra/observability/` → `/opt/familienarchiv/infra/observability/`
|
||||
|
||||
Both the nightly CI job (`nightly.yml`) and the release job (`release.yml`) copy these files from the workspace checkout to `/opt/familienarchiv/` using `cp -r` on every run (CI-push model). Containers always read config from the permanent location; a workspace wipe has no effect on running containers.
|
||||
|
||||
Environment variables follow a two-source model:
|
||||
|
||||
- `infra/observability/obs.env` (git-tracked, non-secret): all non-sensitive config — host ports, public URLs (`GLITCHTIP_DOMAIN`, `GF_SERVER_ROOT_URL`), and the default `POSTGRES_HOST`. Changes go through PR review. No credentials.
|
||||
- `/opt/familienarchiv/obs-secrets.env` (CI-written, per-deploy): passwords and secret keys only (`GRAFANA_ADMIN_PASSWORD`, `GLITCHTIP_SECRET_KEY`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`), injected fresh from Gitea secrets on every nightly and release deploy. Gitea is the single source of truth for secrets — rotating a secret takes effect on the next deploy without manual server action.
|
||||
|
||||
Both files are passed explicitly via `--env-file` to every obs compose command (config dry-run and `up`). There is no implicit auto-read `.env`. The required key inventory is documented in `docs/DEPLOYMENT.md §4`.
|
||||
|
||||
The CI runner mounts `/opt/familienarchiv` as a bind mount into job containers (see `runner-config.yaml`). This requires a one-time `mkdir -p /opt/familienarchiv/infra` on the server and a runner restart after updating `runner-config.yaml` (see ADR-015 and `docs/DEPLOYMENT.md §3.1`).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Bind-mount sources survive workspace wipes by definition — they are on a persistent host path.
|
||||
- Config is always in sync with the repo after each nightly run.
|
||||
- No new infrastructure dependencies; the CI-push model mirrors how the main app stack is deployed.
|
||||
- Secret rotation requires no manual server action — Gitea secrets are the authoritative store; `obs-secrets.env` is rewritten from scratch on every deploy so a secret change takes effect on the next nightly or release run.
|
||||
|
||||
**Negative:**
|
||||
- `cp -r` does not remove deleted files; a config file removed from the repo persists in `/opt/familienarchiv/infra/observability/` until manually deleted. Acceptable for this project's change frequency. A `rsync -a --delete` would give a clean mirror if this becomes a problem.
|
||||
- Mounting `/opt/familienarchiv/` into CI job containers expands the blast radius of a compromised workflow step — a malicious step could overwrite app compose files and Caddy config. Acceptable because the runner is single-tenant (trusted code only). See `runner-config.yaml` security comment.
|
||||
- Runner must be restarted (`systemctl restart gitea-runner`) after any change to `runner-config.yaml` for the new mount to take effect.
|
||||
@@ -1,48 +0,0 @@
|
||||
# ADR-017: Spring Boot 4.0 management port shares the main security filter chain
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The Familienarchiv backend runs Spring Boot Actuator on a dedicated management port (8081) so that Caddy never proxies `/actuator/*` requests and Prometheus can reach the scrape endpoint directly inside `archiv-net`.
|
||||
|
||||
In earlier Spring Boot versions (< 4.0), the management server ran in an isolated child application context whose security was governed independently by `ManagementWebSecurityAutoConfiguration`. The main app's `SecurityConfig` filter chain (port 8080) never intercepted requests arriving on port 8081.
|
||||
|
||||
In Spring Boot 4.0 with Jetty, this isolation was removed. The management server now traverses the **same** Spring Security `FilterChainProxy` as the main application. Concretely:
|
||||
|
||||
- Any `SecurityFilterChain` bean in the application context is evaluated for requests arriving on the management port.
|
||||
- There is no longer a separate "management security" child context.
|
||||
|
||||
This was discovered when Prometheus began receiving HTTP 401 responses from `/actuator/prometheus` despite the endpoint being exposed and the `micrometer-registry-prometheus` dependency being present. Prometheus rejected these responses with `received unsupported Content-Type "text/html"` because the main filter chain's form-login `DelegatingAuthenticationEntryPoint` was redirecting unauthenticated requests to `/login` (302 → HTML).
|
||||
|
||||
A secondary issue: Spring Boot 4.0 no longer auto-enables Prometheus metrics export — `management.prometheus.metrics.export.enabled` must be set explicitly, and the Prometheus scrape endpoint requires `spring-boot-starter-micrometer-metrics` (a new starter that was split out in Spring Boot 4.0).
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Dedicated management `SecurityFilterChain`** scoped to `/actuator/**` at `@Order(1)` (highest precedence). This chain:
|
||||
- `permitAll()` for `/actuator/health` and `/actuator/prometheus` — required for Docker health checks and unauthenticated Prometheus scraping.
|
||||
- `authenticated()` for all other actuator endpoints — blocks `/actuator/metrics`, `/actuator/info`, etc. without credentials.
|
||||
- Uses an explicit `401` entry point (not form-login redirect) so that API clients — including Prometheus — receive a machine-readable status code rather than an HTML redirect.
|
||||
- No CSRF, no form login.
|
||||
|
||||
2. **Belt-and-suspenders `permitAll()` in the main `SecurityFilterChain`** for `/actuator/health` and `/actuator/prometheus`, in case a future configuration change causes these paths to escape the management chain's `securityMatcher`.
|
||||
|
||||
3. **Network isolation as the outer defense boundary.** Port 8081 is not published in `docker-compose.yml` and is not routed through Caddy. Only services inside `archiv-net` (primarily Prometheus and the Docker health checker) can reach the management port.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **Exclude `ManagementWebSecurityAutoConfiguration`:** This auto-configuration no longer exists in Spring Boot 4.0. Exclusion is not applicable.
|
||||
- **Keep `SecurityConfig` as the sole filter chain without `@Order(1)` management chain:** The main chain's form-login `DelegatingAuthenticationEntryPoint` redirects browser-like clients to `/login` (302). Prometheus and automated health check clients cannot follow this redirect, so the endpoint would be unreachable without a dedicated chain that returns plain 401 or 200.
|
||||
- **Per-endpoint `@Order(1)` filter chain using `EndpointRequest.toAnyEndpoint()`:** The `spring-boot-security` artifact that provides `EndpointRequest` is not a transitive dependency of `spring-boot-starter-actuator` in Spring Boot 4.0. Using a path-based `securityMatcher("/actuator/**")` achieves the same scoping without an extra dependency.
|
||||
|
||||
## Consequences
|
||||
|
||||
- All actuator endpoints on port 8081 that are not explicitly `permitAll()`-ed require HTTP Basic credentials. Without valid credentials, the response is 401 (not a redirect).
|
||||
- Adding a new actuator endpoint to `management.endpoints.web.exposure.include` implicitly protects it via `anyRequest().authenticated()` in the management chain — no additional `permitAll()` needed unless intentional.
|
||||
- A regression test (`ActuatorPrometheusIT`) verifies:
|
||||
- `/actuator/prometheus` returns 200 without credentials.
|
||||
- `/actuator/metrics` returns 401 without credentials.
|
||||
- Prometheus metric names are present in the response body.
|
||||
- If port 8081 is ever accidentally published in `docker-compose.yml`, actuator endpoints other than health and prometheus are still protected by HTTP Basic. This reduces (but does not eliminate) the risk of inadvertent exposure.
|
||||
@@ -1,86 +0,0 @@
|
||||
# ADR-018: GlitchTip frontend error tracking via @sentry/sveltekit
|
||||
|
||||
**Date:** 2026-05-17
|
||||
**Status:** Accepted
|
||||
**Deciders:** Marcel Raddatz
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The Familienarchiv had no client-side error reporting. When a user encountered a crash
|
||||
or unhandled error in the SvelteKit frontend, there was no way for the operator to
|
||||
observe it — errors were invisible until a user manually reported them. A GlitchTip
|
||||
instance (self-hosted, Sentry-compatible) was already running as part of the
|
||||
observability stack (`docker-compose.observability.yml`). The backend already reported
|
||||
server-side errors to it.
|
||||
|
||||
We needed a way to:
|
||||
1. Capture frontend errors automatically and route them to GlitchTip.
|
||||
2. Give users a visible error identifier they can include in a support message.
|
||||
3. Do this without leaking personally identifiable information (PII) from the family
|
||||
archive — documents contain personal histories, names, and relationships.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Use `@sentry/sveltekit` (the official Sentry SDK for SvelteKit) to:
|
||||
|
||||
- Initialise with `sendDefaultPii: false` on both `hooks.server.ts` and `hooks.client.ts`.
|
||||
- Pass a callback to `Sentry.handleErrorWithSentry()` that returns
|
||||
`{ message, errorId }` where `errorId` is `Sentry.lastEventId()` when Sentry
|
||||
captured the event, or a fresh `crypto.randomUUID()` as fallback.
|
||||
- Display the `errorId` on the `+error.svelte` page so users can include it in a
|
||||
report to the operator.
|
||||
|
||||
The SDK is initialised with `enabled: !!import.meta.env.VITE_SENTRY_DSN` so that
|
||||
development and CI builds without a DSN configured do not send any events.
|
||||
|
||||
`VITE_SENTRY_DSN` is a write-only ingest key — it can POST events to GlitchTip but
|
||||
cannot read them. It is safe to include in the client bundle per the Sentry security
|
||||
model; it does not require rotation like a password.
|
||||
|
||||
---
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Sentry SaaS** — rejected. The archive contains private family documents and personal
|
||||
history. Sending error events with stack traces to a US-hosted third party is
|
||||
inconsistent with the project's data-minimisation posture. Self-hosted GlitchTip on
|
||||
the same Hetzner VPS keeps all data on infrastructure the operator controls.
|
||||
|
||||
**Custom error logging endpoint** — rejected. The @sentry/sveltekit SDK handles
|
||||
SvelteKit's hook lifecycle, source-map upload, and event grouping automatically.
|
||||
Reimplementing this would cost significant engineering time for no benefit.
|
||||
|
||||
**Log-only (no user-visible errorId)** — rejected. Without a visible error ID, users
|
||||
can only describe what happened in natural language, making it hard to correlate a
|
||||
report with a specific GlitchTip event. The `errorId` closes this gap at negligible UI
|
||||
cost.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Frontend errors are now observable without requiring user reports.
|
||||
- Users can provide an `errorId` that maps directly to a GlitchTip event.
|
||||
- `sendDefaultPii: false` ensures names, IPs, and cookie values are not included in
|
||||
captured events.
|
||||
- `tracesSampleRate: 0.1` limits trace volume to 10% of transactions, keeping
|
||||
GlitchTip load low on the shared VPS.
|
||||
|
||||
**Negative / trade-offs:**
|
||||
- The `@sentry/sveltekit` SDK is now a production dependency. SDK updates must be
|
||||
reviewed for changes to the default PII scrubbing behaviour.
|
||||
- The `handleError` callback in both hooks returns a hardcoded English message
|
||||
(`'An unexpected error occurred'`). This bypasses Paraglide i18n — the error page
|
||||
will always show English text when the hooks are active, regardless of the user's
|
||||
locale. This is acceptable because: (a) the error page is a last-resort fallback
|
||||
not part of normal UX, (b) the `errorId` is the actionable information, not the
|
||||
message text. A future ADR may address this if internationalised error messages
|
||||
become a requirement.
|
||||
- `Sentry.lastEventId()` returns `undefined` when Sentry did not capture the event
|
||||
(e.g. DSN not configured). The `crypto.randomUUID()` fallback guarantees an `errorId`
|
||||
is always present, but that UUID will not appear in GlitchTip.
|
||||
@@ -8,11 +8,9 @@ Person(member, "Family Member", "Access by administrator invite. Searches, brows
|
||||
|
||||
System(familienarchiv, "Familienarchiv", "Web application for digitising, organising, and searching family documents")
|
||||
System_Ext(mail, "Email Service", "SMTP server. Delivers notification emails (mentions, replies) and password-reset links.")
|
||||
System_Ext(glitchtip, "GlitchTip", "Self-hosted error tracking (Sentry-compatible). Receives frontend and backend error events with stack traces.")
|
||||
|
||||
Rel(admin, familienarchiv, "Manages via browser", "HTTPS")
|
||||
Rel(member, familienarchiv, "Searches, reads, and transcribes via browser", "HTTPS")
|
||||
Rel(familienarchiv, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
Rel(familienarchiv, glitchtip, "Sends error events with errorId and stack trace", "HTTPS")
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -17,19 +17,6 @@ System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
|
||||
Container(mc, "Bucket / Service-Account Init", "MinIO Client (mc)", "One-shot container on startup. Idempotent: creates the archive bucket, the archiv-app service account, and attaches the readwrite policy.")
|
||||
}
|
||||
|
||||
System_Boundary(observability, "Observability Stack (/opt/familienarchiv/docker-compose.observability.yml)") {
|
||||
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend management port 8081 (/actuator/prometheus), node-exporter, and cAdvisor. Retention: 30 days.")
|
||||
Container(node_exporter, "Node Exporter", "prom/node-exporter:v1.9.0", "Host-level CPU, memory, disk, and network metrics.")
|
||||
Container(cadvisor, "cAdvisor", "gcr.io/cadvisor/cadvisor:v0.52.1", "Per-container resource metrics.")
|
||||
Container(loki, "Loki", "grafana/loki:3.4.2", "Stores log streams from all containers.")
|
||||
Container(promtail, "Promtail", "grafana/promtail:3.4.2", "Ships Docker container logs to Loki via Docker SD.")
|
||||
Container(tempo, "Tempo", "grafana/tempo:2.7.2", "Distributed trace storage. OTLP HTTP receiver on port 4318 (archiv-net). Grafana queries traces on port 3200 (obs-net). All ports internal only.")
|
||||
Container(grafana, "Grafana", "grafana/grafana-oss:11.6.1", "Unified observability UI — dashboards, logs, traces. Datasources (Prometheus, Loki, Tempo) and three dashboards are auto-provisioned.")
|
||||
Container(glitchtip, "GlitchTip", "glitchtip/glitchtip:6.1.6", "Sentry-compatible error tracker — web process. Receives frontend + backend error events, groups by fingerprint, provides issue UI with stack traces.")
|
||||
Container(obs_glitchtip_worker, "GlitchTip Worker", "glitchtip/glitchtip:6.1.6", "Celery + beat worker — async event ingestion, notifications, cleanup.")
|
||||
Container(obs_redis, "Redis", "redis:7-alpine", "Celery task queue for GlitchTip async workers.")
|
||||
}
|
||||
|
||||
Rel(user, caddy, "HTTPS", "TLS 1.2/1.3")
|
||||
Rel(caddy, frontend, "Reverse proxies non-/api requests", "HTTP / loopback:3000")
|
||||
Rel(caddy, backend, "Reverse proxies /api/*", "HTTP / loopback:8080")
|
||||
@@ -41,12 +28,5 @@ Rel(backend, ocr, "OCR job requests with presigned MinIO URL", "HTTP / REST / JS
|
||||
Rel(backend, mail, "Sends notification and password-reset emails (optional)", "SMTP")
|
||||
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(grafana, prometheus, "Queries metrics", "HTTP 9090")
|
||||
Rel(grafana, loki, "Queries logs", "HTTP 3100")
|
||||
Rel(grafana, tempo, "Queries traces", "HTTP 3200")
|
||||
Rel(glitchtip, db, "Stores error events in glitchtip DB", "PostgreSQL / archiv-net")
|
||||
Rel(obs_glitchtip_worker, obs_redis, "Processes Celery tasks", "Redis / obs-net")
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -19,39 +19,6 @@ Both containers live in the `gitea_gitea` Docker network on the VPS. The runner
|
||||
|
||||
The `gitea-runner` container mounts the host Docker socket (`/var/run/docker.sock`). When a workflow job runs, act_runner spawns a **sibling container** for each job. That job container also gets the Docker socket mounted (via `valid_volumes` in `runner-config.yaml`), enabling `docker compose` calls in workflow steps.
|
||||
|
||||
### Workspace bind-mount setup (DooD path resolution)
|
||||
|
||||
When a workflow step calls `docker compose up` with relative bind-mount sources (e.g. `./infra/observability/prometheus/prometheus.yml`), Compose resolves them against `$(pwd)` inside the job container and passes the resulting **absolute path** to the host Docker daemon. The host daemon then tries to bind-mount that path from the **host filesystem**.
|
||||
|
||||
In the default DooD setup the job container's workspace lives in the act_runner overlay2 layer — the host has no directory at that path, auto-creates an empty one, and the container fails with:
|
||||
|
||||
```
|
||||
error mounting "…/prometheus/prometheus.yml" to rootfs at "/etc/prometheus/prometheus.yml": not a directory
|
||||
```
|
||||
|
||||
**Solution (ADR-015):** store job workspaces on a real host path and mount it at the **same absolute path** inside the runner and every job container. `runner-config.yaml` configures this via `workdir_parent`, `valid_volumes`, and `options`.
|
||||
|
||||
**One-time host setup** (required on any fresh VPS):
|
||||
|
||||
```bash
|
||||
mkdir -p /srv/gitea-workspace
|
||||
# Then add to the runner service in ~/docker/gitea/compose.yaml:
|
||||
# volumes:
|
||||
# - /srv/gitea-workspace:/srv/gitea-workspace
|
||||
# Restart the runner container for the change to take effect.
|
||||
```
|
||||
|
||||
The path `/srv/gitea-workspace` is the canonical workspace root. It must be identical on the host and inside job containers — if the paths differ, Compose still resolves to the container-internal path, which the host daemon cannot find (the original bug).
|
||||
|
||||
**Disk management:** act_runner cleans per-run subdirectories on completion. Orphaned directories from interrupted runs accumulate under `/srv/gitea-workspace` and should be pruned manually if disk space becomes a concern:
|
||||
|
||||
```bash
|
||||
# List workspace directories older than 7 days
|
||||
find /srv/gitea-workspace -mindepth 3 -maxdepth 3 -type d -mtime +7
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Running host-level commands from CI (nsenter pattern)
|
||||
|
||||
Job containers are unprivileged and do not share the host's PID/mount/network namespaces. Commands like `systemctl` that target the host daemon are therefore unavailable by default. When a workflow step needs to manage a host service (e.g. `systemctl reload caddy`), it uses the Docker socket to spin up a **privileged sibling container** in the host PID namespace:
|
||||
@@ -141,33 +108,6 @@ nsenter: failed to execute /bin/systemctl: No such file or directory
|
||||
|
||||
The first error means the Docker socket is not mounted into the job container — check `valid_volumes` in `/root/docker/gitea/runner-config.yaml` on the VPS. The second means the Alpine image is running but cannot enter the host mount namespace; verify `--privileged` and `--pid=host` are both present in the workflow step.
|
||||
|
||||
**Failure mode 4 — workspace bind-mount not configured (observability stack or any compose-with-file-mounts job)**
|
||||
|
||||
Symptom in CI log:
|
||||
```
|
||||
Error response from daemon: error while creating mount source path "…/prometheus/prometheus.yml": mkdir …: not a directory
|
||||
```
|
||||
|
||||
Or the service starts but immediately crashes because a config file was mounted as an empty directory.
|
||||
|
||||
Cause: `/srv/gitea-workspace` does not exist on the host, or the runner container's `compose.yaml` is missing the `- /srv/gitea-workspace:/srv/gitea-workspace` volume line.
|
||||
|
||||
Diagnosis:
|
||||
```bash
|
||||
ssh root@<vps>
|
||||
ls -la /srv/gitea-workspace # must exist and be a directory
|
||||
docker inspect gitea-runner | grep -A5 Mounts # must show /srv/gitea-workspace
|
||||
```
|
||||
|
||||
Recovery:
|
||||
```bash
|
||||
mkdir -p /srv/gitea-workspace
|
||||
# Add volume line to runner compose.yaml, then:
|
||||
docker compose -f ~/docker/gitea/compose.yaml up -d gitea-runner
|
||||
```
|
||||
|
||||
See `docs/DEPLOYMENT.md §3.1` and ADR-015 for the full setup rationale.
|
||||
|
||||
---
|
||||
|
||||
## Gitea vs GitHub Actions Differences
|
||||
@@ -260,7 +200,7 @@ jobs:
|
||||
working-directory: frontend
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
|
||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
||||
with:
|
||||
name: unit-test-screenshots
|
||||
path: frontend/test-results/screenshots/
|
||||
@@ -287,7 +227,7 @@ jobs:
|
||||
working-directory: backend
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
|
||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
||||
with:
|
||||
name: backend-test-results
|
||||
path: backend/target/surefire-reports/
|
||||
@@ -389,7 +329,7 @@ jobs:
|
||||
E2E_BACKEND_URL: http://localhost:8080
|
||||
- name: Upload E2E results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3 # pinned per ADR-014 — Gitea Actions does not implement v4 protocol. Do NOT upgrade.
|
||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
||||
with:
|
||||
name: e2e-results
|
||||
path: frontend/test-results/e2e/
|
||||
|
||||
@@ -12,11 +12,11 @@ The original spec in this doc proposed an overlay pattern (`docker compose -f do
|
||||
|
||||
---
|
||||
|
||||
## Observability stack
|
||||
## Observability stack — not yet deployed
|
||||
|
||||
The observability stack (Prometheus, Loki, Grafana, Tempo, GlitchTip) ships as a separate `docker-compose.observability.yml` alongside the main stack. Configuration lives under `infra/observability/`.
|
||||
Prometheus, Loki, Grafana, Alertmanager, Uptime Kuma, GlitchTip and ntfy are **not** part of the production deployment that #497 landed. They are tracked as follow-up issue #498.
|
||||
|
||||
→ See [docs/DEPLOYMENT.md §4](../DEPLOYMENT.md#4-logs--observability) for the full setup procedure, service URLs, first-run steps, and env var reference.
|
||||
When that lands the observability containers will join `docker-compose.prod.yml` under a dedicated profile so they can be operated alongside the application stack without affecting the application containers' restart cycle.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ src/
|
||||
│ ├── profile/ # User profile settings
|
||||
│ ├── users/[id]/ # Public user profile page
|
||||
│ ├── login/ logout/ register/
|
||||
│ └── forgot-password/ reset-password/
|
||||
│ ├── forgot-password/ reset-password/
|
||||
│ └── demo/ # Dev-only demos
|
||||
├── lib/ # Domain-based package structure (mirrors backend)
|
||||
│ ├── document/ # Document domain: components, stores, services, utils
|
||||
│ │ ├── annotation/ # Annotation overlay components
|
||||
@@ -165,7 +166,7 @@ npm run check # svelte-check (type checking)
|
||||
|
||||
```bash
|
||||
npm run test # Vitest unit + server tests (headless)
|
||||
npm run test:coverage # Coverage report (server + client)
|
||||
npm run test:coverage # Coverage report (server project only)
|
||||
npm run test:e2e # Playwright E2E tests
|
||||
npm run test:e2e:headed # Playwright E2E with visible browser
|
||||
npm run test:e2e:ui # Playwright UI mode
|
||||
|
||||
@@ -29,6 +29,6 @@ ENV NODE_ENV=production
|
||||
COPY --from=build /app/build ./build
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/package-lock.json ./package-lock.json
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
RUN npm ci --omit=dev
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build"]
|
||||
|
||||
@@ -38,16 +38,14 @@ export default defineConfig(
|
||||
'no-undef': 'off',
|
||||
// This rule is designed for Svelte 5's own routing system using resolve().
|
||||
// In SvelteKit, <a href> and goto() from $app/navigation are the correct patterns — resolve() is not needed.
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
// Prevents accidental console.log left in source. console.warn and console.error
|
||||
// are still permitted for intentional server-side logging (e.g. hooks.server.ts).
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }]
|
||||
'svelte/no-navigation-without-resolve': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
@@ -74,38 +72,6 @@ export default defineConfig(
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
// E2E tests use console.log for diagnostic output — allow it there.
|
||||
files: ['e2e/**'],
|
||||
rules: {
|
||||
'no-console': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', '**/*.test.ts'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.object.name='vi'][callee.property.name='mock'] > Literal[value=/^pdfjs-dist/]",
|
||||
message:
|
||||
"Banned: vi.mock('pdfjs-dist', factory) causes a birpc teardown race in browser-mode specs — see ADR 012. Use the libLoader prop injection pattern instead."
|
||||
},
|
||||
{
|
||||
// ADR 012 / #553. The named mechanism: an async vi.mock factory whose
|
||||
// body performs `await import(...)` produces a late birpc roundtrip
|
||||
// during worker teardown. The factory body must be synchronous; if
|
||||
// you need to share state between the spec and the mock, use
|
||||
// `vi.hoisted` (see DropZone.svelte.spec.ts).
|
||||
selector:
|
||||
"CallExpression[callee.object.name='vi'][callee.property.name='mock'][arguments.1.type='ArrowFunctionExpression'][arguments.1.async=true]:has(AwaitExpression > ImportExpression)",
|
||||
message:
|
||||
'Banned: vi.mock(..., async () => { await import(...) }) causes a birpc teardown race in browser-mode specs — see ADR 012. Use a synchronous factory + vi.hoisted instead.'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
plugins: { boundaries },
|
||||
settings: {
|
||||
|
||||
@@ -345,11 +345,8 @@
|
||||
"admin_system_import_btn_retry": "Erneut starten",
|
||||
"admin_system_import_status_idle": "Kein Import gestartet.",
|
||||
"admin_system_import_status_running": "Import läuft…",
|
||||
"admin_system_import_status_done": "Import abgeschlossen",
|
||||
"admin_system_import_status_done_label": "Dokumente verarbeitet",
|
||||
"admin_system_import_status_failed": "Import fehlgeschlagen",
|
||||
"admin_system_import_failed_no_spreadsheet": "Keine Tabellendatei gefunden.",
|
||||
"admin_system_import_failed_internal": "Interner Fehler beim Import.",
|
||||
"admin_system_import_status_done": "Import abgeschlossen – {count} Dokumente verarbeitet.",
|
||||
"admin_system_import_status_failed": "Fehler: {message}",
|
||||
"admin_system_thumbnails_heading": "Thumbnails erzeugen",
|
||||
"admin_system_thumbnails_description": "Erzeugt Vorschaubilder für Dokumente ohne Thumbnail (z. B. nach dem Massenimport).",
|
||||
"admin_system_thumbnails_btn_start": "Thumbnails erzeugen",
|
||||
@@ -473,7 +470,7 @@
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Gesch.",
|
||||
"dashboard_reader_draft_meta": "Entwurf · zuletzt bearbeitet {relative}",
|
||||
"dashboard_resume_label": "Weiter, wo du aufgehört hast",
|
||||
"dashboard_resume_label": "Zuletzt geöffnet:",
|
||||
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
||||
"doc_status_placeholder": "Platzhalter",
|
||||
"doc_status_uploaded": "Hochgeladen",
|
||||
@@ -706,8 +703,6 @@
|
||||
"error_invite_exhausted": "Dieser Einladungslink wurde bereits vollständig verwendet.",
|
||||
"error_invite_revoked": "Dieser Einladungslink wurde deaktiviert.",
|
||||
"error_invite_expired": "Dieser Einladungslink ist abgelaufen.",
|
||||
"error_group_has_active_invites": "Diese Gruppe kann nicht gelöscht werden, da sie in einer aktiven Einladung verwendet wird.",
|
||||
"error_group_not_found": "Die angegebene Gruppe existiert nicht.",
|
||||
"register_heading": "Konto erstellen",
|
||||
"register_subtext": "Du wurdest eingeladen, dem Familienarchiv beizutreten.",
|
||||
"register_label_first_name": "Vorname",
|
||||
@@ -767,21 +762,22 @@
|
||||
"admin_new_invite_prefill_last": "Nachname vorausfüllen (optional)",
|
||||
"admin_new_invite_prefill_email": "E-Mail vorausfüllen (optional)",
|
||||
"admin_new_invite_expires": "Ablaufdatum (optional)",
|
||||
"admin_new_invite_groups": "Gruppen (optional)",
|
||||
"admin_new_invite_no_groups": "Keine Gruppen vorhanden.",
|
||||
"admin_invite_groups_load_error": "Gruppen konnten nicht geladen werden. Die Einladung kann ohne Gruppenauswahl erstellt werden.",
|
||||
"admin_invite_created_title": "Einladung erstellt",
|
||||
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
|
||||
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",
|
||||
|
||||
"greeting_morning": "Guten Morgen, {name}.",
|
||||
"greeting_day": "Hallo, {name}.",
|
||||
"greeting_evening": "Guten Abend, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Weiter, wo du aufgehört hast",
|
||||
"dashboard_blocks": "{count} Abschnitte",
|
||||
"dashboard_resume_cta": "Weitertranskribieren",
|
||||
"dashboard_resume_other": "oder anderen Brief wählen",
|
||||
"dashboard_empty_title": "Noch kein Dokument begonnen",
|
||||
"dashboard_empty_body": "Wähle ein Dokument aus dem Archiv, um mit der Transkription zu beginnen.",
|
||||
"dashboard_empty_cta": "Zum Archiv",
|
||||
|
||||
"dashboard_mission_caption": "Offene Aufgaben",
|
||||
"queue_segment": "Segmentieren",
|
||||
"queue_segment_blurb": "Seiten aufteilen",
|
||||
@@ -791,6 +787,7 @@
|
||||
"queue_review_blurb": "Texte kontrollieren",
|
||||
"queue_n_open": "{n} offen",
|
||||
"queue_show_all": "Alle anzeigen →",
|
||||
|
||||
"pulse_eyebrow": "Diese Woche",
|
||||
"pulse_headline": "Ihr habt {pages} Seiten bearbeitet.",
|
||||
"pulse_you": "Du selbst hast {pages} davon bearbeitet.",
|
||||
@@ -798,15 +795,19 @@
|
||||
"pulse_transcribed": "Textstellen markiert",
|
||||
"pulse_reviewed": "Textstellen transkribiert",
|
||||
"pulse_uploaded": "Dokumente hochgeladen",
|
||||
|
||||
"feed_caption": "Kommentare & Aktivität",
|
||||
"feed_show_all": "Alle anzeigen",
|
||||
"feed_for_you": "für dich",
|
||||
|
||||
"audit_action_text_saved": "hat Text gespeichert in",
|
||||
"audit_action_file_uploaded": "hat eine Datei hochgeladen:",
|
||||
"audit_action_annotation_created": "hat eine Markierung erstellt in",
|
||||
"audit_action_comment_added": "hat kommentiert:",
|
||||
"audit_action_mention_created": "hat dich erwähnt in",
|
||||
|
||||
"dropzone_release": "Loslassen zum Hochladen",
|
||||
|
||||
"chronik_page_title": "Aktivitäten",
|
||||
"chronik_for_you_caption": "Für dich",
|
||||
"chronik_for_you_count": "{count} neu",
|
||||
@@ -850,7 +851,9 @@
|
||||
"pagination_page_of": "Seite {page} von {total}",
|
||||
"pagination_nav_label": "Seitennavigation",
|
||||
"pagination_page_button": "Seite {page}",
|
||||
|
||||
"common_opens_new_tab": "(öffnet in neuem Tab)",
|
||||
|
||||
"transcribe_coach_title": "Erste Transkription?",
|
||||
"transcribe_coach_preamble": "Unser Kurrent-Erkenner lernt noch. Jede Transkription, die Sie zum Training freigeben, bringt ihm die Schrift bei — so funktioniert's:",
|
||||
"transcribe_coach_step_1_title": "Rahmen ziehen.",
|
||||
@@ -860,8 +863,10 @@
|
||||
"transcribe_coach_step_3_title": "Speichert automatisch.",
|
||||
"transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗",
|
||||
|
||||
"transcription_mode_help_label": "Lese- und Bearbeitungsmodus",
|
||||
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
|
||||
|
||||
"richtlinien_title": "Transkriptions-Richtlinien",
|
||||
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
|
||||
"richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.",
|
||||
@@ -935,9 +940,12 @@
|
||||
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
|
||||
"bulk_edit_topbar_title": "Massenbearbeitung",
|
||||
"bulk_edit_count_pill": "{count} werden bearbeitet",
|
||||
|
||||
"nav_stammbaum": "Stammbaum",
|
||||
"nav_geschichten": "Geschichten",
|
||||
|
||||
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||
|
||||
"geschichten_index_title": "Geschichten",
|
||||
"geschichten_new_button": "Neue Geschichte",
|
||||
"geschichten_filter_all_pill": "Alle",
|
||||
@@ -957,6 +965,7 @@
|
||||
"geschichten_card_attach_action": "+ Geschichte anhängen",
|
||||
"geschichten_card_show_all_for_person": "Alle Geschichten zu {name}",
|
||||
"geschichten_card_show_all": "Alle anzeigen",
|
||||
|
||||
"geschichte_editor_title_placeholder": "Titel der Geschichte",
|
||||
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
|
||||
"geschichte_editor_status_draft": "ENTWURF",
|
||||
@@ -983,11 +992,14 @@
|
||||
"geschichte_editor_toolbar_h3": "Unterüberschrift",
|
||||
"geschichte_editor_toolbar_ul": "Aufzählung",
|
||||
"geschichte_editor_toolbar_ol": "Nummerierte Liste",
|
||||
|
||||
"geschichte_delete_confirm_title": "Geschichte löschen?",
|
||||
"geschichte_delete_confirm_body": "Diese Aktion kann nicht rückgängig gemacht werden. Die Geschichte wird dauerhaft gelöscht und aus allen verlinkten Personen- und Dokumentseiten entfernt.",
|
||||
|
||||
"error_relationship_not_found": "Die Beziehung wurde nicht gefunden.",
|
||||
"error_circular_relationship": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||
"error_duplicate_relationship": "Diese Beziehung gibt es bereits.",
|
||||
|
||||
"relation_parent_of": "Elternteil von",
|
||||
"relation_child_of": "Kind von",
|
||||
"relation_spouse_of": "Ehegatte",
|
||||
@@ -998,6 +1010,7 @@
|
||||
"relation_doctor": "Arzt",
|
||||
"relation_neighbor": "Nachbar",
|
||||
"relation_other": "Sonstige",
|
||||
|
||||
"relation_inferred_parent": "Elternteil",
|
||||
"relation_inferred_child": "Kind",
|
||||
"relation_inferred_spouse": "Ehegatte",
|
||||
@@ -1015,7 +1028,9 @@
|
||||
"relation_inferred_sibling_inlaw": "Schwager/Schwägerin",
|
||||
"relation_inferred_cousin_1": "Cousin/Cousine",
|
||||
"relation_inferred_distant": "Weitläufige Verwandtschaft",
|
||||
|
||||
"doc_details_field_relationship": "Verwandtschaft",
|
||||
|
||||
"stammbaum_empty_heading": "Noch keine Familienmitglieder",
|
||||
"stammbaum_empty_body": "Markiere Personen auf ihrer Bearbeitungsseite als Familienmitglied, damit sie hier erscheinen.",
|
||||
"stammbaum_empty_link": "→ Zur Personenliste",
|
||||
@@ -1027,6 +1042,7 @@
|
||||
"stammbaum_zoom_in": "Vergrößern",
|
||||
"stammbaum_zoom_out": "Verkleinern",
|
||||
"stammbaum_generations": "Generationen",
|
||||
|
||||
"relation_error_duplicate": "Diese Beziehung gibt es bereits.",
|
||||
"relation_error_circular": "Diese Beziehung würde einen Kreis erzeugen.",
|
||||
"relation_error_self": "Eine Person kann nicht mit sich selbst verbunden werden.",
|
||||
@@ -1049,15 +1065,14 @@
|
||||
"relation_form_field_from_year": "Von Jahr",
|
||||
"relation_form_field_to_year": "Bis Jahr",
|
||||
"relation_form_year_placeholder": "z.B. 1920",
|
||||
|
||||
"person_relationships_heading": "Beziehungen",
|
||||
"person_relationships_empty": "Noch keine Beziehungen bekannt.",
|
||||
|
||||
"timeline_aria_label": "Zeitachse Dokumentdichte",
|
||||
"timeline_clear_selection": "Auswahl zurücksetzen",
|
||||
"timeline_zoom_reset": "Zurück zur Übersicht",
|
||||
"timeline_bar_aria_singular": "{when}, 1 Dokument",
|
||||
"timeline_bar_aria_plural": "{when}, {count} Dokumente",
|
||||
"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!"
|
||||
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt"
|
||||
}
|
||||
|
||||
@@ -345,11 +345,8 @@
|
||||
"admin_system_import_btn_retry": "Start again",
|
||||
"admin_system_import_status_idle": "No import started.",
|
||||
"admin_system_import_status_running": "Import running…",
|
||||
"admin_system_import_status_done": "Import complete",
|
||||
"admin_system_import_status_done_label": "Documents processed",
|
||||
"admin_system_import_status_failed": "Import failed",
|
||||
"admin_system_import_failed_no_spreadsheet": "No spreadsheet file found.",
|
||||
"admin_system_import_failed_internal": "Import failed due to an internal error.",
|
||||
"admin_system_import_status_done": "Import complete – {count} documents processed.",
|
||||
"admin_system_import_status_failed": "Error: {message}",
|
||||
"admin_system_thumbnails_heading": "Generate thumbnails",
|
||||
"admin_system_thumbnails_description": "Generates preview images for documents without a thumbnail (e.g. after the mass import).",
|
||||
"admin_system_thumbnails_btn_start": "Generate thumbnails",
|
||||
@@ -473,7 +470,7 @@
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Stor.",
|
||||
"dashboard_reader_draft_meta": "Draft · last edited {relative}",
|
||||
"dashboard_resume_label": "Continue where you left off",
|
||||
"dashboard_resume_label": "Last opened:",
|
||||
"dashboard_resume_fallback": "Unknown document",
|
||||
"doc_status_placeholder": "Placeholder",
|
||||
"doc_status_uploaded": "Uploaded",
|
||||
@@ -706,8 +703,6 @@
|
||||
"error_invite_exhausted": "This invite link has already been fully used.",
|
||||
"error_invite_revoked": "This invite link has been deactivated.",
|
||||
"error_invite_expired": "This invite link has expired.",
|
||||
"error_group_has_active_invites": "This group cannot be deleted because it is referenced by one or more active invite links.",
|
||||
"error_group_not_found": "The specified group does not exist.",
|
||||
"register_heading": "Create account",
|
||||
"register_subtext": "You've been invited to join Familienarchiv.",
|
||||
"register_label_first_name": "First name",
|
||||
@@ -767,21 +762,22 @@
|
||||
"admin_new_invite_prefill_last": "Pre-fill last name (optional)",
|
||||
"admin_new_invite_prefill_email": "Pre-fill email (optional)",
|
||||
"admin_new_invite_expires": "Expiry date (optional)",
|
||||
"admin_new_invite_groups": "Groups (optional)",
|
||||
"admin_new_invite_no_groups": "No groups exist.",
|
||||
"admin_invite_groups_load_error": "Groups could not be loaded. The invite can still be created without group assignment.",
|
||||
"admin_invite_created_title": "Invite created",
|
||||
"admin_invite_created_desc": "Share this link with the person you are inviting:",
|
||||
"admin_invite_revoke_confirm": "Really revoke this invite?",
|
||||
|
||||
"greeting_morning": "Good morning, {name}.",
|
||||
"greeting_day": "Hello, {name}.",
|
||||
"greeting_evening": "Good evening, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Continue where you left off",
|
||||
"dashboard_blocks": "{count} sections",
|
||||
"dashboard_resume_cta": "Continue transcribing",
|
||||
"dashboard_resume_other": "or choose another document",
|
||||
"dashboard_empty_title": "No document started yet",
|
||||
"dashboard_empty_body": "Choose a document from the archive to start transcribing.",
|
||||
"dashboard_empty_cta": "To the archive",
|
||||
|
||||
"dashboard_mission_caption": "Open tasks",
|
||||
"queue_segment": "Segment",
|
||||
"queue_segment_blurb": "Split pages",
|
||||
@@ -791,6 +787,7 @@
|
||||
"queue_review_blurb": "Check texts",
|
||||
"queue_n_open": "{n} open",
|
||||
"queue_show_all": "Show all →",
|
||||
|
||||
"pulse_eyebrow": "This week",
|
||||
"pulse_headline": "You have worked on {pages} pages.",
|
||||
"pulse_you": "You personally worked on {pages} of them.",
|
||||
@@ -798,15 +795,19 @@
|
||||
"pulse_transcribed": "Passages annotated",
|
||||
"pulse_reviewed": "Passages transcribed",
|
||||
"pulse_uploaded": "Documents uploaded",
|
||||
|
||||
"feed_caption": "Comments & activity",
|
||||
"feed_show_all": "Show all",
|
||||
"feed_for_you": "for you",
|
||||
|
||||
"audit_action_text_saved": "saved text in",
|
||||
"audit_action_file_uploaded": "uploaded a file:",
|
||||
"audit_action_annotation_created": "created an annotation in",
|
||||
"audit_action_comment_added": "commented:",
|
||||
"audit_action_mention_created": "mentioned you in",
|
||||
|
||||
"dropzone_release": "Release to upload",
|
||||
|
||||
"chronik_page_title": "Activity",
|
||||
"chronik_for_you_caption": "For you",
|
||||
"chronik_for_you_count": "{count} new",
|
||||
@@ -850,7 +851,9 @@
|
||||
"pagination_page_of": "Page {page} of {total}",
|
||||
"pagination_nav_label": "Pagination",
|
||||
"pagination_page_button": "Page {page}",
|
||||
|
||||
"common_opens_new_tab": "(opens in new tab)",
|
||||
|
||||
"transcribe_coach_title": "First transcription?",
|
||||
"transcribe_coach_preamble": "Our Kurrent recogniser is still learning. Every transcription you release for training teaches it the handwriting — here's how it works:",
|
||||
"transcribe_coach_step_1_title": "Draw a frame.",
|
||||
@@ -860,8 +863,10 @@
|
||||
"transcribe_coach_step_3_title": "Saves automatically.",
|
||||
"transcribe_coach_footer_kurrent": "Kurrent help ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Transcription guidelines ↗",
|
||||
|
||||
"transcription_mode_help_label": "Read and edit mode",
|
||||
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
|
||||
|
||||
"richtlinien_title": "Transcription Guidelines",
|
||||
"richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
|
||||
"richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.",
|
||||
@@ -935,9 +940,12 @@
|
||||
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
|
||||
"bulk_edit_topbar_title": "Bulk edit",
|
||||
"bulk_edit_count_pill": "{count} will be edited",
|
||||
|
||||
"nav_stammbaum": "Family tree",
|
||||
"nav_geschichten": "Stories",
|
||||
|
||||
"error_geschichte_not_found": "The story was not found.",
|
||||
|
||||
"geschichten_index_title": "Stories",
|
||||
"geschichten_new_button": "New story",
|
||||
"geschichten_filter_all_pill": "All",
|
||||
@@ -957,6 +965,7 @@
|
||||
"geschichten_card_attach_action": "+ Attach a story",
|
||||
"geschichten_card_show_all_for_person": "All stories about {name}",
|
||||
"geschichten_card_show_all": "Show all",
|
||||
|
||||
"geschichte_editor_title_placeholder": "Story title",
|
||||
"geschichte_editor_body_placeholder": "Write your story here…",
|
||||
"geschichte_editor_status_draft": "DRAFT",
|
||||
@@ -983,11 +992,14 @@
|
||||
"geschichte_editor_toolbar_h3": "Subheading",
|
||||
"geschichte_editor_toolbar_ul": "Bulleted list",
|
||||
"geschichte_editor_toolbar_ol": "Numbered list",
|
||||
|
||||
"geschichte_delete_confirm_title": "Delete story?",
|
||||
"geschichte_delete_confirm_body": "This action cannot be undone. The story will be permanently deleted and removed from all linked person and document pages.",
|
||||
|
||||
"error_relationship_not_found": "Relationship not found.",
|
||||
"error_circular_relationship": "This relationship would form a cycle.",
|
||||
"error_duplicate_relationship": "This relationship already exists.",
|
||||
|
||||
"relation_parent_of": "Parent of",
|
||||
"relation_child_of": "Child of",
|
||||
"relation_spouse_of": "Spouse",
|
||||
@@ -998,6 +1010,7 @@
|
||||
"relation_doctor": "Doctor",
|
||||
"relation_neighbor": "Neighbour",
|
||||
"relation_other": "Other",
|
||||
|
||||
"relation_inferred_parent": "Parent",
|
||||
"relation_inferred_child": "Child",
|
||||
"relation_inferred_spouse": "Spouse",
|
||||
@@ -1015,7 +1028,9 @@
|
||||
"relation_inferred_sibling_inlaw": "Sibling-in-law",
|
||||
"relation_inferred_cousin_1": "Cousin",
|
||||
"relation_inferred_distant": "Distant relative",
|
||||
|
||||
"doc_details_field_relationship": "Relationship",
|
||||
|
||||
"stammbaum_empty_heading": "No family members yet",
|
||||
"stammbaum_empty_body": "Mark a person as a family member on their edit page so they appear here.",
|
||||
"stammbaum_empty_link": "→ Go to person list",
|
||||
@@ -1027,6 +1042,7 @@
|
||||
"stammbaum_zoom_in": "Zoom in",
|
||||
"stammbaum_zoom_out": "Zoom out",
|
||||
"stammbaum_generations": "Generations",
|
||||
|
||||
"relation_error_duplicate": "This relationship already exists.",
|
||||
"relation_error_circular": "This relationship would form a cycle.",
|
||||
"relation_error_self": "A person cannot be related to themselves.",
|
||||
@@ -1049,15 +1065,14 @@
|
||||
"relation_form_field_from_year": "From year",
|
||||
"relation_form_field_to_year": "To year",
|
||||
"relation_form_year_placeholder": "e.g. 1920",
|
||||
|
||||
"person_relationships_heading": "Relationships",
|
||||
"person_relationships_empty": "No relationships known yet.",
|
||||
|
||||
"timeline_aria_label": "Document density timeline",
|
||||
"timeline_clear_selection": "Clear selection",
|
||||
"timeline_zoom_reset": "Reset zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 document",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documents",
|
||||
"timeline_dragging_aria_live": "Range {from} to {to} selected",
|
||||
"error_page_id_label": "Error ID",
|
||||
"error_copy_id_label": "Copy ID",
|
||||
"error_copied": "Copied!"
|
||||
"timeline_dragging_aria_live": "Range {from} to {to} selected"
|
||||
}
|
||||
|
||||
@@ -345,11 +345,8 @@
|
||||
"admin_system_import_btn_retry": "Iniciar de nuevo",
|
||||
"admin_system_import_status_idle": "No hay importación iniciada.",
|
||||
"admin_system_import_status_running": "Importación en curso…",
|
||||
"admin_system_import_status_done": "Importación completada",
|
||||
"admin_system_import_status_done_label": "Documentos procesados",
|
||||
"admin_system_import_status_failed": "Importación fallida",
|
||||
"admin_system_import_failed_no_spreadsheet": "No se encontró ninguna hoja de cálculo.",
|
||||
"admin_system_import_failed_internal": "Error interno durante la importación.",
|
||||
"admin_system_import_status_done": "Importación completada – {count} documentos procesados.",
|
||||
"admin_system_import_status_failed": "Error: {message}",
|
||||
"admin_system_thumbnails_heading": "Generar miniaturas",
|
||||
"admin_system_thumbnails_description": "Genera imágenes de vista previa para documentos sin miniatura (p. ej. tras la importación masiva).",
|
||||
"admin_system_thumbnails_btn_start": "Generar miniaturas",
|
||||
@@ -473,7 +470,7 @@
|
||||
"dashboard_reader_stats_persons_short": "Pers.",
|
||||
"dashboard_reader_stats_stories_short": "Hist.",
|
||||
"dashboard_reader_draft_meta": "Borrador · editado hace {relative}",
|
||||
"dashboard_resume_label": "Continuar donde lo dejaste",
|
||||
"dashboard_resume_label": "Último abierto:",
|
||||
"dashboard_resume_fallback": "Documento desconocido",
|
||||
"doc_status_placeholder": "Marcador",
|
||||
"doc_status_uploaded": "Cargado",
|
||||
@@ -706,8 +703,6 @@
|
||||
"error_invite_exhausted": "Este enlace de invitación ya ha sido completamente utilizado.",
|
||||
"error_invite_revoked": "Este enlace de invitación ha sido desactivado.",
|
||||
"error_invite_expired": "Este enlace de invitación ha expirado.",
|
||||
"error_group_has_active_invites": "Este grupo no puede eliminarse porque está referenciado por uno o más enlaces de invitación activos.",
|
||||
"error_group_not_found": "El grupo especificado no existe.",
|
||||
"register_heading": "Crear cuenta",
|
||||
"register_subtext": "Has sido invitado a unirte al Familienarchiv.",
|
||||
"register_label_first_name": "Nombre",
|
||||
@@ -767,21 +762,22 @@
|
||||
"admin_new_invite_prefill_last": "Prellenar apellido (opcional)",
|
||||
"admin_new_invite_prefill_email": "Prellenar correo (opcional)",
|
||||
"admin_new_invite_expires": "Fecha de vencimiento (opcional)",
|
||||
"admin_new_invite_groups": "Grupos (opcional)",
|
||||
"admin_new_invite_no_groups": "No hay grupos disponibles.",
|
||||
"admin_invite_groups_load_error": "No se pudieron cargar los grupos. La invitación puede crearse sin asignar grupos.",
|
||||
"admin_invite_created_title": "Invitación creada",
|
||||
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
|
||||
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",
|
||||
|
||||
"greeting_morning": "Buenos días, {name}.",
|
||||
"greeting_day": "Hola, {name}.",
|
||||
"greeting_evening": "Buenas noches, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Continuar donde lo dejaste",
|
||||
"dashboard_blocks": "{count} secciones",
|
||||
"dashboard_resume_cta": "Continuar transcripción",
|
||||
"dashboard_resume_other": "o elige otro documento",
|
||||
"dashboard_empty_title": "Aún no has comenzado ningún documento",
|
||||
"dashboard_empty_body": "Elige un documento del archivo para empezar a transcribir.",
|
||||
"dashboard_empty_cta": "Al archivo",
|
||||
|
||||
"dashboard_mission_caption": "Tareas pendientes",
|
||||
"queue_segment": "Segmentar",
|
||||
"queue_segment_blurb": "Dividir páginas",
|
||||
@@ -791,6 +787,7 @@
|
||||
"queue_review_blurb": "Controlar textos",
|
||||
"queue_n_open": "{n} pendiente",
|
||||
"queue_show_all": "Ver todo →",
|
||||
|
||||
"pulse_eyebrow": "Esta semana",
|
||||
"pulse_headline": "Habéis trabajado {pages} páginas.",
|
||||
"pulse_you": "Tú mismo has trabajado {pages} de ellas.",
|
||||
@@ -798,15 +795,19 @@
|
||||
"pulse_transcribed": "Fragmentos anotados",
|
||||
"pulse_reviewed": "Fragmentos transcritos",
|
||||
"pulse_uploaded": "Documentos subidos",
|
||||
|
||||
"feed_caption": "Comentarios y actividad",
|
||||
"feed_show_all": "Ver todo",
|
||||
"feed_for_you": "para ti",
|
||||
|
||||
"audit_action_text_saved": "guardó texto en",
|
||||
"audit_action_file_uploaded": "subió un archivo:",
|
||||
"audit_action_annotation_created": "creó una anotación en",
|
||||
"audit_action_comment_added": "comentó:",
|
||||
"audit_action_mention_created": "te mencionó en",
|
||||
|
||||
"dropzone_release": "Suelta para subir",
|
||||
|
||||
"chronik_page_title": "Actividades",
|
||||
"chronik_for_you_caption": "Para ti",
|
||||
"chronik_for_you_count": "{count} nuevas",
|
||||
@@ -850,7 +851,9 @@
|
||||
"pagination_page_of": "Página {page} de {total}",
|
||||
"pagination_nav_label": "Paginación",
|
||||
"pagination_page_button": "Página {page}",
|
||||
|
||||
"common_opens_new_tab": "(abre en pestaña nueva)",
|
||||
|
||||
"transcribe_coach_title": "¿Primera transcripción?",
|
||||
"transcribe_coach_preamble": "Nuestro reconocedor de Kurrent aún está aprendiendo. Cada transcripción que libera para el entrenamiento le enseña la escritura — así funciona:",
|
||||
"transcribe_coach_step_1_title": "Dibujar un marco.",
|
||||
@@ -860,8 +863,10 @@
|
||||
"transcribe_coach_step_3_title": "Se guarda automáticamente.",
|
||||
"transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗",
|
||||
"transcribe_coach_footer_richtlinien": "Normas de transcripción ↗",
|
||||
|
||||
"transcription_mode_help_label": "Modo lectura y edición",
|
||||
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
|
||||
|
||||
"richtlinien_title": "Normas de transcripción",
|
||||
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.",
|
||||
"richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.",
|
||||
@@ -935,9 +940,12 @@
|
||||
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
|
||||
"bulk_edit_topbar_title": "Edición masiva",
|
||||
"bulk_edit_count_pill": "Se editarán {count}",
|
||||
|
||||
"nav_stammbaum": "Árbol genealógico",
|
||||
"nav_geschichten": "Historias",
|
||||
|
||||
"error_geschichte_not_found": "No se encontró la historia.",
|
||||
|
||||
"geschichten_index_title": "Historias",
|
||||
"geschichten_new_button": "Nueva historia",
|
||||
"geschichten_filter_all_pill": "Todas",
|
||||
@@ -957,6 +965,7 @@
|
||||
"geschichten_card_attach_action": "+ Adjuntar historia",
|
||||
"geschichten_card_show_all_for_person": "Todas las historias sobre {name}",
|
||||
"geschichten_card_show_all": "Mostrar todas",
|
||||
|
||||
"geschichte_editor_title_placeholder": "Título de la historia",
|
||||
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
|
||||
"geschichte_editor_status_draft": "BORRADOR",
|
||||
@@ -983,11 +992,14 @@
|
||||
"geschichte_editor_toolbar_h3": "Subencabezado",
|
||||
"geschichte_editor_toolbar_ul": "Lista con viñetas",
|
||||
"geschichte_editor_toolbar_ol": "Lista numerada",
|
||||
|
||||
"geschichte_delete_confirm_title": "¿Eliminar historia?",
|
||||
"geschichte_delete_confirm_body": "Esta acción no se puede deshacer. La historia se eliminará permanentemente y se quitará de todas las páginas de personas y documentos vinculados.",
|
||||
|
||||
"error_relationship_not_found": "La relación no fue encontrada.",
|
||||
"error_circular_relationship": "Esta relación crearía un ciclo.",
|
||||
"error_duplicate_relationship": "Esta relación ya existe.",
|
||||
|
||||
"relation_parent_of": "Progenitor de",
|
||||
"relation_child_of": "Hijo/a de",
|
||||
"relation_spouse_of": "Cónyuge",
|
||||
@@ -998,6 +1010,7 @@
|
||||
"relation_doctor": "Médico",
|
||||
"relation_neighbor": "Vecino/a",
|
||||
"relation_other": "Otro",
|
||||
|
||||
"relation_inferred_parent": "Progenitor",
|
||||
"relation_inferred_child": "Hijo/a",
|
||||
"relation_inferred_spouse": "Cónyuge",
|
||||
@@ -1015,7 +1028,9 @@
|
||||
"relation_inferred_sibling_inlaw": "Cuñado/a",
|
||||
"relation_inferred_cousin_1": "Primo/a",
|
||||
"relation_inferred_distant": "Pariente lejano",
|
||||
|
||||
"doc_details_field_relationship": "Parentesco",
|
||||
|
||||
"stammbaum_empty_heading": "Aún no hay miembros de la familia",
|
||||
"stammbaum_empty_body": "Marca a una persona como miembro de la familia en su página de edición para que aparezca aquí.",
|
||||
"stammbaum_empty_link": "→ Ir a la lista de personas",
|
||||
@@ -1027,6 +1042,7 @@
|
||||
"stammbaum_zoom_in": "Acercar",
|
||||
"stammbaum_zoom_out": "Alejar",
|
||||
"stammbaum_generations": "Generaciones",
|
||||
|
||||
"relation_error_duplicate": "Esta relación ya existe.",
|
||||
"relation_error_circular": "Esta relación crearía un ciclo.",
|
||||
"relation_error_self": "Una persona no puede estar relacionada consigo misma.",
|
||||
@@ -1049,15 +1065,14 @@
|
||||
"relation_form_field_from_year": "Desde año",
|
||||
"relation_form_field_to_year": "Hasta año",
|
||||
"relation_form_year_placeholder": "ej. 1920",
|
||||
|
||||
"person_relationships_heading": "Relaciones",
|
||||
"person_relationships_empty": "Aún no se conocen relaciones.",
|
||||
|
||||
"timeline_aria_label": "Cronología de densidad de documentos",
|
||||
"timeline_clear_selection": "Borrar selección",
|
||||
"timeline_zoom_reset": "Restablecer zoom",
|
||||
"timeline_bar_aria_singular": "{when}, 1 documento",
|
||||
"timeline_bar_aria_plural": "{when}, {count} documentos",
|
||||
"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!"
|
||||
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado"
|
||||
}
|
||||
|
||||
2063
frontend/package-lock.json
generated
2063
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || true && git -C .. config core.hooksPath .husky 2>/dev/null || true",
|
||||
"postinstall": "patch-package",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
@@ -16,14 +15,13 @@
|
||||
"lint:boundary-demo": "eslint src/lib/tag/__fixtures__/",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run",
|
||||
"test:coverage": "vitest run --coverage --project=server; vitest run -c vitest.client-coverage.config.ts --coverage",
|
||||
"test:coverage": "vitest run --coverage --project=server && vitest run -c vitest.client-coverage.config.ts --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/sveltekit": "^10.53.1",
|
||||
"@tiptap/core": "3.22.5",
|
||||
"@tiptap/extension-mention": "3.22.5",
|
||||
"@tiptap/starter-kit": "3.22.5",
|
||||
@@ -56,7 +54,6 @@
|
||||
"eslint-plugin-svelte": "^3.13.0",
|
||||
"globals": "^16.5.0",
|
||||
"openapi-typescript": "^7.8.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"playwright": "^1.56.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
diff --git a/node_modules/@vitest/browser-playwright/dist/index.js b/node_modules/@vitest/browser-playwright/dist/index.js
|
||||
index 5d0d37b..821d7b4 100644
|
||||
--- a/node_modules/@vitest/browser-playwright/dist/index.js
|
||||
+++ b/node_modules/@vitest/browser-playwright/dist/index.js
|
||||
@@ -935,7 +935,7 @@ class PlaywrightBrowserProvider {
|
||||
createMocker() {
|
||||
const idPreficates = new Map();
|
||||
const sessionIds = new Map();
|
||||
- function createPredicate(sessionId, url) {
|
||||
+ function createPredicate(url) {
|
||||
const moduleUrl = new URL(url, "http://localhost");
|
||||
const predicate = (url) => {
|
||||
if (url.searchParams.has("_vitest_original")) {
|
||||
@@ -960,11 +960,7 @@ class PlaywrightBrowserProvider {
|
||||
}
|
||||
return true;
|
||||
};
|
||||
- const ids = sessionIds.get(sessionId) || [];
|
||||
- ids.push(moduleUrl.href);
|
||||
- sessionIds.set(sessionId, ids);
|
||||
- idPreficates.set(predicateKey(sessionId, moduleUrl.href), predicate);
|
||||
- return predicate;
|
||||
+ return { url: moduleUrl.href, predicate };
|
||||
}
|
||||
function predicateKey(sessionId, url) {
|
||||
return `${sessionId}:${url}`;
|
||||
@@ -972,7 +968,23 @@ class PlaywrightBrowserProvider {
|
||||
return {
|
||||
register: async (sessionId, module) => {
|
||||
const page = this.getPage(sessionId);
|
||||
- await page.context().route(createPredicate(sessionId, module.url), async (route) => {
|
||||
+ const { url: moduleUrl, predicate } = createPredicate(module.url);
|
||||
+ const key = predicateKey(sessionId, moduleUrl);
|
||||
+ // Backport of vitest PR #10267: if a route handler is already
|
||||
+ // registered for this resolved module URL in this session,
|
||||
+ // unroute it before installing the new one. Without this guard,
|
||||
+ // duplicate-id mocks (e.g. '$lib/foo.svelte' + '$lib/foo.svelte.js')
|
||||
+ // leak an orphan route whose handler crashes after the next
|
||||
+ // session's birpc channel closes.
|
||||
+ const existingPredicate = idPreficates.get(key);
|
||||
+ if (existingPredicate) {
|
||||
+ await page.context().unroute(existingPredicate);
|
||||
+ }
|
||||
+ const ids = sessionIds.get(sessionId) ?? new Set();
|
||||
+ ids.add(moduleUrl);
|
||||
+ sessionIds.set(sessionId, ids);
|
||||
+ idPreficates.set(key, predicate);
|
||||
+ await page.context().route(predicate, async (route) => {
|
||||
if (module.type === "manual") {
|
||||
const exports$1 = Object.keys(await module.resolve());
|
||||
const body = createManualModuleSource(module.url, exports$1);
|
||||
@@ -1033,8 +1045,8 @@ class PlaywrightBrowserProvider {
|
||||
},
|
||||
clear: async (sessionId) => {
|
||||
const page = this.getPage(sessionId);
|
||||
- const ids = sessionIds.get(sessionId) || [];
|
||||
- const promises = ids.map((id) => {
|
||||
+ const ids = sessionIds.get(sessionId) ?? new Set();
|
||||
+ const promises = [...ids].map((id) => {
|
||||
const key = predicateKey(sessionId, id);
|
||||
const predicate = idPreficates.get(key);
|
||||
if (predicate) {
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Browser-mode tests must run with SvelteKit's hover-prefetch disabled.
|
||||
// Hover-prefetch fires real `fetch` requests for the target route's loader
|
||||
// chunks; those go through the same Playwright route handler that serves
|
||||
// mocked modules. Even after `cleanup()` tears down the iframe, an in-flight
|
||||
// prefetch can still hit the handler — and if the worker's birpc channel has
|
||||
// closed by then, the handler raises an unhandled rejection. ADR-012 / #553.
|
||||
//
|
||||
// This test enforces that the test-setup file ran and switched preload-data
|
||||
// off on `document.body` before any spec started rendering.
|
||||
describe('browser test setup', () => {
|
||||
it('disables SvelteKit loader-data prefetch on document.body', () => {
|
||||
expect(document.body.dataset.sveltekitPreloadData).toBe('off');
|
||||
});
|
||||
|
||||
it('disables SvelteKit route-code prefetch on document.body', () => {
|
||||
expect(document.body.dataset.sveltekitPreloadCode).toBe('off');
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Belt-and-braces detector for the birpc teardown race named in ADR-012 / #553.
|
||||
// ESLint catches the pattern at save time, CI grep catches it before the test
|
||||
// suite launches, and this in-suite test catches it at every vitest invocation —
|
||||
// the layer hardest to disable or scope around.
|
||||
//
|
||||
// We scan source text rather than parsing AST: fast, no parser dependency,
|
||||
// good enough for the named anti-pattern. The pattern matches
|
||||
// `vi.mock(<arg>, async ... { ... await import(...) ... })`.
|
||||
|
||||
const ASYNC_MOCK_WITH_DYNAMIC_IMPORT = /vi\.mock\([^)]*,\s*async[^{]*\{[\s\S]*?await\s+import\s*\(/;
|
||||
|
||||
export function hasAsyncMockFactoryWithDynamicImport(source: string): boolean {
|
||||
return ASYNC_MOCK_WITH_DYNAMIC_IMPORT.test(source);
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SRC_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
function findBrowserSpecs(): string[] {
|
||||
const entries = readdirSync(SRC_ROOT, { recursive: true, withFileTypes: true });
|
||||
return entries
|
||||
.filter(
|
||||
(e) =>
|
||||
e.isFile() && (e.name.endsWith('.svelte.test.ts') || e.name.endsWith('.svelte.spec.ts'))
|
||||
)
|
||||
.map((e) => path.join(e.parentPath ?? (e as { path: string }).path, e.name));
|
||||
}
|
||||
|
||||
describe('scan: hasAsyncMockFactoryWithDynamicImport', () => {
|
||||
it('flags async vi.mock factory with await import in body', () => {
|
||||
const fixture = `vi.mock('$app/stores', async () => {
|
||||
const mod = await import('./__mocks__/navigatingStore');
|
||||
return { navigating: mod.navigatingStore };
|
||||
});`;
|
||||
expect(hasAsyncMockFactoryWithDynamicImport(fixture)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag sync vi.mock factory', () => {
|
||||
const fixture = `vi.mock('$app/state', () => ({ navigating: { type: null } }));`;
|
||||
expect(hasAsyncMockFactoryWithDynamicImport(fixture)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag async vi.mock factory without dynamic import', () => {
|
||||
const fixture = `vi.mock('foo', async () => {
|
||||
const x = await Promise.resolve(42);
|
||||
return { bar: x };
|
||||
});`;
|
||||
expect(hasAsyncMockFactoryWithDynamicImport(fixture)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not flag dynamic import outside any vi.mock', () => {
|
||||
const fixture = `async function load() {
|
||||
const mod = await import('./something');
|
||||
return mod.default;
|
||||
}`;
|
||||
expect(hasAsyncMockFactoryWithDynamicImport(fixture)).toBe(false);
|
||||
});
|
||||
|
||||
it('flags async factory written as async function expression', () => {
|
||||
const fixture = `vi.mock('foo', async function () {
|
||||
const mod = await import('./bar');
|
||||
return mod;
|
||||
});`;
|
||||
expect(hasAsyncMockFactoryWithDynamicImport(fixture)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser specs: no async vi.mock factory contains await import', () => {
|
||||
it('every src/**/*.svelte.{test,spec}.ts file is clean', () => {
|
||||
const specFiles = findBrowserSpecs();
|
||||
expect(specFiles.length).toBeGreaterThan(0);
|
||||
const offenders = specFiles.filter((file) =>
|
||||
hasAsyncMockFactoryWithDynamicImport(readFileSync(file, 'utf-8'))
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Belt-and-braces detector for the duplicate-id birpc race named in
|
||||
// ADR-012 / #553. When the same resolved module URL is mocked via two
|
||||
// distinct vi.mock id strings (e.g. '$lib/foo.svelte' and
|
||||
// '$lib/foo.svelte.js'), @vitest/browser-playwright registers two
|
||||
// Playwright routes against one cleanup slot — the orphan survives, fires
|
||||
// after the next session's birpc closes, and crashes the run with
|
||||
// "[birpc] rpc is closed, cannot call resolveManualMock".
|
||||
//
|
||||
// Fixed upstream in vitest PR #10267; until that fix reaches a published
|
||||
// release, normalisation in user-land is the practical guard. This test
|
||||
// catches the pattern at every vitest invocation — the layer hardest to
|
||||
// disable or scope around.
|
||||
|
||||
const VI_MOCK_ID = /vi\.mock\(\s*['"]([^'"]+)['"]/g;
|
||||
|
||||
function extractMockIds(source: string): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const match of source.matchAll(VI_MOCK_ID)) {
|
||||
ids.push(match[1]);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function canonicalise(id: string): string {
|
||||
if (id.endsWith('.svelte.js')) return id.slice(0, -3);
|
||||
if (id.endsWith('.svelte.ts')) return id.slice(0, -3);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function findDuplicateMockIds(
|
||||
specSources: Record<string, string>
|
||||
): Map<string, Set<string>> {
|
||||
const byCanonical = new Map<string, Set<string>>();
|
||||
for (const source of Object.values(specSources)) {
|
||||
for (const raw of extractMockIds(source)) {
|
||||
const canonical = canonicalise(raw);
|
||||
const existing = byCanonical.get(canonical) ?? new Set<string>();
|
||||
existing.add(raw);
|
||||
byCanonical.set(canonical, existing);
|
||||
}
|
||||
}
|
||||
const duplicates = new Map<string, Set<string>>();
|
||||
for (const [canonical, raws] of byCanonical) {
|
||||
if (raws.size >= 2) duplicates.set(canonical, raws);
|
||||
}
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SRC_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
function findBrowserSpecs(): string[] {
|
||||
const entries = readdirSync(SRC_ROOT, { recursive: true, withFileTypes: true });
|
||||
return entries
|
||||
.filter(
|
||||
(e) =>
|
||||
e.isFile() && (e.name.endsWith('.svelte.test.ts') || e.name.endsWith('.svelte.spec.ts'))
|
||||
)
|
||||
.map((e) => path.join(e.parentPath ?? (e as { path: string }).path, e.name));
|
||||
}
|
||||
|
||||
describe('scan: findDuplicateMockIds', () => {
|
||||
it('flags two specs mocking the same module under .svelte and .svelte.js', () => {
|
||||
const dup = findDuplicateMockIds({
|
||||
'a.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));`,
|
||||
'b.spec.ts': `vi.mock('$lib/foo.svelte.js', () => ({}));`
|
||||
});
|
||||
expect(dup.get('$lib/foo.svelte')).toEqual(new Set(['$lib/foo.svelte', '$lib/foo.svelte.js']));
|
||||
});
|
||||
|
||||
it('does not flag two specs both using $lib/foo.svelte', () => {
|
||||
const dup = findDuplicateMockIds({
|
||||
'a.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));`,
|
||||
'b.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));`
|
||||
});
|
||||
expect(dup.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not flag $app/state and $app/stores (different modules)', () => {
|
||||
const dup = findDuplicateMockIds({
|
||||
'a.spec.ts': `vi.mock('$app/state', () => ({}));`,
|
||||
'b.spec.ts': `vi.mock('$app/stores', () => ({}));`
|
||||
});
|
||||
expect(dup.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not flag $lib/foo and $lib/bar (different canonical paths)', () => {
|
||||
const dup = findDuplicateMockIds({
|
||||
'a.spec.ts': `vi.mock('$lib/foo', () => ({}));`,
|
||||
'b.spec.ts': `vi.mock('$lib/bar', () => ({}));`
|
||||
});
|
||||
expect(dup.size).toBe(0);
|
||||
});
|
||||
|
||||
it('flags both spellings within a single file', () => {
|
||||
const dup = findDuplicateMockIds({
|
||||
'a.spec.ts': `
|
||||
vi.mock('$lib/foo.svelte', () => ({}));
|
||||
vi.mock('$lib/foo.svelte.js', () => ({}));
|
||||
`
|
||||
});
|
||||
expect(dup.get('$lib/foo.svelte')?.size).toBe(2);
|
||||
});
|
||||
|
||||
it('canonicalises .svelte.ts the same way as .svelte.js', () => {
|
||||
const dup = findDuplicateMockIds({
|
||||
'a.spec.ts': `vi.mock('$lib/foo.svelte', () => ({}));`,
|
||||
'b.spec.ts': `vi.mock('$lib/foo.svelte.ts', () => ({}));`
|
||||
});
|
||||
expect(dup.get('$lib/foo.svelte')?.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browser specs: no duplicate-id vi.mock calls across the suite', () => {
|
||||
it('every mocked module is referenced under exactly one id string', () => {
|
||||
const specFiles = findBrowserSpecs();
|
||||
expect(specFiles.length).toBeGreaterThan(0);
|
||||
const sources = Object.fromEntries(
|
||||
specFiles.map((file) => [file, readFileSync(file, 'utf-8')])
|
||||
);
|
||||
const duplicates = findDuplicateMockIds(sources);
|
||||
const report = Object.fromEntries([...duplicates].map(([k, v]) => [k, [...v]]));
|
||||
expect(report).toEqual({});
|
||||
});
|
||||
});
|
||||
5
frontend/src/app.d.ts
vendored
5
frontend/src/app.d.ts
vendored
@@ -26,11 +26,6 @@ declare global {
|
||||
interface PageData {
|
||||
user?: User; // Available in $page.data.user
|
||||
}
|
||||
|
||||
interface Error {
|
||||
message: string;
|
||||
errorId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@sentry/sveltekit', () => ({
|
||||
init: vi.fn(),
|
||||
handleErrorWithSentry: (fn: (args: unknown) => unknown) => fn,
|
||||
lastEventId: vi.fn(() => 'sentry-event-id-abc123')
|
||||
}));
|
||||
|
||||
describe('hooks.client handleError', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('returns Sentry lastEventId as errorId', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue('sentry-event-id-abc123');
|
||||
|
||||
const { handleError } = await import('./hooks.client');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: {},
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toBe('sentry-event-id-abc123');
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
|
||||
it('falls back to crypto.randomUUID when lastEventId returns undefined', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue(undefined);
|
||||
|
||||
const { handleError } = await import('./hooks.client');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: {},
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
|
||||
// VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip
|
||||
// but cannot read them. Safe to include in the client bundle per Sentry security model.
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
environment: import.meta.env.MODE,
|
||||
tracesSampleRate: 0.1,
|
||||
sendDefaultPii: false,
|
||||
enabled: !!import.meta.env.VITE_SENTRY_DSN
|
||||
});
|
||||
|
||||
export const handleError = Sentry.handleErrorWithSentry(() => {
|
||||
const errorId = Sentry.lastEventId() ?? crypto.randomUUID();
|
||||
return { message: 'An unexpected error occurred', errorId };
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@sentry/sveltekit', () => ({
|
||||
init: vi.fn(),
|
||||
handleErrorWithSentry: (fn: (args: unknown) => unknown) => fn,
|
||||
lastEventId: vi.fn(() => 'sentry-event-id-abc123')
|
||||
}));
|
||||
|
||||
vi.mock('@sveltejs/kit', () => ({ redirect: vi.fn() }));
|
||||
vi.mock('@sveltejs/kit/hooks', () => ({ sequence: vi.fn((...fns: unknown[]) => fns[0]) }));
|
||||
vi.mock('$lib/paraglide/server', () => ({ paraglideMiddleware: vi.fn() }));
|
||||
vi.mock('$lib/paraglide/runtime', () => ({ cookieName: 'locale', cookieMaxAge: 86400 }));
|
||||
vi.mock('$lib/shared/server/locale', () => ({ detectLocale: vi.fn(() => 'de') }));
|
||||
|
||||
const makeEvent = () => ({
|
||||
url: { pathname: '/documents/123' },
|
||||
locals: {}
|
||||
});
|
||||
|
||||
describe('hooks.server handleError', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('returns Sentry lastEventId as errorId', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue('sentry-event-id-abc123');
|
||||
|
||||
const { handleError } = await import('./hooks.server');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: makeEvent(),
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toBe('sentry-event-id-abc123');
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
|
||||
it('falls back to crypto.randomUUID when lastEventId returns undefined', async () => {
|
||||
const Sentry = await import('@sentry/sveltekit');
|
||||
vi.mocked(Sentry.lastEventId).mockReturnValue(undefined);
|
||||
|
||||
const { handleError } = await import('./hooks.server');
|
||||
const result = (handleError as (args: unknown) => { message: string; errorId: string })({
|
||||
error: new Error('boom'),
|
||||
event: makeEvent(),
|
||||
status: 500,
|
||||
message: 'Internal Error'
|
||||
});
|
||||
|
||||
expect(result.errorId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
expect(result.message).toBe('An unexpected error occurred');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
import { redirect, type Handle, type HandleFetch } from '@sveltejs/kit';
|
||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
@@ -6,16 +5,6 @@ import { env } from 'process';
|
||||
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
|
||||
import { detectLocale } from '$lib/shared/server/locale';
|
||||
|
||||
// VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip
|
||||
// but cannot read them. Safe to include in the client bundle per Sentry security model.
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
environment: import.meta.env.MODE,
|
||||
tracesSampleRate: 0.1,
|
||||
sendDefaultPii: false,
|
||||
enabled: !!import.meta.env.VITE_SENTRY_DSN
|
||||
});
|
||||
|
||||
const PUBLIC_PATHS = [
|
||||
'/login',
|
||||
'/logout',
|
||||
@@ -124,8 +113,3 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
};
|
||||
|
||||
export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide);
|
||||
|
||||
export const handleError = Sentry.handleErrorWithSentry(() => {
|
||||
const errorId = Sentry.lastEventId() ?? crypto.randomUUID();
|
||||
return { message: 'An unexpected error occurred', errorId };
|
||||
});
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import ChronikEmptyState from './ChronikEmptyState.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ChronikEmptyState', () => {
|
||||
it('renders the first-run title and body and the clock icon', async () => {
|
||||
render(ChronikEmptyState, { props: { variant: 'first-run' as const } });
|
||||
|
||||
await expect.element(page.getByText('Noch nichts geschehen')).toBeVisible();
|
||||
await expect.element(page.getByText(/sobald jemand aus der familie/i)).toBeVisible();
|
||||
|
||||
const wrapper = document.querySelector('[data-testid="chronik-empty-state"]');
|
||||
expect(wrapper?.getAttribute('data-variant')).toBe('first-run');
|
||||
});
|
||||
|
||||
it('renders the filter-empty title and body', async () => {
|
||||
render(ChronikEmptyState, { props: { variant: 'filter-empty' as const } });
|
||||
|
||||
await expect.element(page.getByText('Nichts in dieser Ansicht')).toBeVisible();
|
||||
await expect.element(page.getByText('In diesem Filter gibt es keine Einträge.')).toBeVisible();
|
||||
|
||||
const wrapper = document.querySelector('[data-testid="chronik-empty-state"]');
|
||||
expect(wrapper?.getAttribute('data-variant')).toBe('filter-empty');
|
||||
});
|
||||
|
||||
it('renders the inbox-zero title and no body paragraph', async () => {
|
||||
render(ChronikEmptyState, { props: { variant: 'inbox-zero' as const } });
|
||||
|
||||
await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeVisible();
|
||||
|
||||
// Only one <p> (the title) since body is empty
|
||||
const wrapper = document.querySelector('[data-testid="chronik-empty-state"]');
|
||||
const paragraphs = wrapper?.querySelectorAll('p');
|
||||
expect(paragraphs?.length).toBe(1);
|
||||
expect(wrapper?.getAttribute('data-variant')).toBe('inbox-zero');
|
||||
});
|
||||
|
||||
it('uses the accent color icon for inbox-zero (vs ink-3 for others)', async () => {
|
||||
render(ChronikEmptyState, { props: { variant: 'inbox-zero' as const } });
|
||||
|
||||
const wrapper = document.querySelector('[data-testid="chronik-empty-state"]');
|
||||
const svg = wrapper?.querySelector('svg');
|
||||
expect(svg?.getAttribute('class')).toContain('text-accent');
|
||||
});
|
||||
|
||||
it('uses the ink-3 color icon for first-run', async () => {
|
||||
render(ChronikEmptyState, { props: { variant: 'first-run' as const } });
|
||||
|
||||
const wrapper = document.querySelector('[data-testid="chronik-empty-state"]');
|
||||
const svg = wrapper?.querySelector('svg');
|
||||
expect(svg?.getAttribute('class')).toContain('text-ink-3');
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import ChronikErrorCard from './ChronikErrorCard.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ChronikErrorCard', () => {
|
||||
it('renders the default error message when no message is supplied', async () => {
|
||||
render(ChronikErrorCard, { props: { onRetry: () => {} } });
|
||||
|
||||
await expect.element(page.getByText(/Aktivitäten konnten nicht/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the supplied message when provided', async () => {
|
||||
render(ChronikErrorCard, {
|
||||
props: { onRetry: () => {}, message: 'Custom error message' }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Custom error message')).toBeVisible();
|
||||
});
|
||||
|
||||
it('calls onRetry when the retry button is clicked', async () => {
|
||||
const onRetry = vi.fn();
|
||||
render(ChronikErrorCard, { props: { onRetry } });
|
||||
|
||||
await page.getByRole('button', { name: /erneut versuchen/i }).click();
|
||||
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('marks the card as role="alert" for assistive tech', async () => {
|
||||
render(ChronikErrorCard, { props: { onRetry: () => {} } });
|
||||
|
||||
await expect.element(page.getByRole('alert')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import ChronikFilterPills from './ChronikFilterPills.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ChronikFilterPills', () => {
|
||||
it('renders the radiogroup with the label', async () => {
|
||||
render(ChronikFilterPills, { props: { value: 'alle' as const, onChange: () => {} } });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('radiogroup', { name: /aktivitäten filtern/i }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('renders all five filter pills', async () => {
|
||||
render(ChronikFilterPills, { props: { value: 'alle' as const, onChange: () => {} } });
|
||||
|
||||
const radios = document.querySelectorAll('[role="radio"]');
|
||||
expect(radios.length).toBe(5);
|
||||
});
|
||||
|
||||
it('marks the active filter as aria-checked=true', async () => {
|
||||
render(ChronikFilterPills, { props: { value: 'fuer-dich' as const, onChange: () => {} } });
|
||||
|
||||
const active = document.querySelector('[data-filter-value="fuer-dich"]') as HTMLElement;
|
||||
expect(active.getAttribute('aria-checked')).toBe('true');
|
||||
});
|
||||
|
||||
it('sets tabindex=0 on the active pill and -1 on others', async () => {
|
||||
render(ChronikFilterPills, { props: { value: 'kommentare' as const, onChange: () => {} } });
|
||||
|
||||
const active = document.querySelector('[data-filter-value="kommentare"]') as HTMLElement;
|
||||
const others = Array.from(document.querySelectorAll('[role="radio"]')).filter(
|
||||
(el) => el !== active
|
||||
) as HTMLElement[];
|
||||
expect(active.tabIndex).toBe(0);
|
||||
others.forEach((el) => expect(el.tabIndex).toBe(-1));
|
||||
});
|
||||
|
||||
it('calls onChange with the new filter value when clicked', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(ChronikFilterPills, { props: { value: 'alle' as const, onChange } });
|
||||
|
||||
const transcription = document.querySelector(
|
||||
'[data-filter-value="transkription"]'
|
||||
) as HTMLElement;
|
||||
transcription.click();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('transkription');
|
||||
});
|
||||
});
|
||||
@@ -79,7 +79,7 @@ function href(n: NotificationItem): string {
|
||||
<ul role="list" class="flex flex-col gap-2">
|
||||
{#each unread as n (n.id)}
|
||||
<li
|
||||
class="chronik-fade-in group flex items-start gap-3 rounded-sm p-2 transition-colors hover:bg-canvas"
|
||||
class="fade-in group flex items-start gap-3 rounded-sm p-2 transition-colors hover:bg-canvas"
|
||||
>
|
||||
<a
|
||||
href={href(n)}
|
||||
@@ -124,3 +124,26 @@ function href(n: NotificationItem): string {
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.fade-in {
|
||||
animation: chronik-fade-in 160ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes chronik-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fade-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
|
||||
import type { NotificationItem } from '$lib/notification/notifications';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const mention = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
|
||||
id: 'n-1',
|
||||
type: 'MENTION',
|
||||
documentId: 'doc-1',
|
||||
referenceId: 'ref-1',
|
||||
annotationId: null,
|
||||
read: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
actorName: 'Anna',
|
||||
documentTitle: 'Brief 1899',
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('ChronikFuerDichBox', () => {
|
||||
it('renders the inbox-zero state when there are no unread', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
|
||||
const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the count badge with the unread count', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const badge = document.querySelector('[data-testid="chronik-fuerdich-count"]');
|
||||
expect(badge?.textContent).toContain('3');
|
||||
});
|
||||
|
||||
it('uses the @ glyph for MENTION and ↩ for REPLY', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const items = document.querySelectorAll('ul[role="list"] li');
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0].textContent).toContain('@');
|
||||
expect(items[1].textContent).toContain('↩');
|
||||
});
|
||||
|
||||
it('renders MENTION verb text from paraglide messages', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ actorName: 'Bertha' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByText(/bertha hat dich in einem kommentar erwähnt/i))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('renders REPLY verb text from paraglide messages', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByText(/carl hat auf deinen kommentar geantwortet/i))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
|
||||
const onMarkRead = vi.fn();
|
||||
const item = mention({ id: 'n-7' });
|
||||
render(ChronikFuerDichBox, {
|
||||
props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
|
||||
});
|
||||
|
||||
const dismiss = document.querySelector(
|
||||
'[data-testid="chronik-fuerdich-dismiss"]'
|
||||
) as HTMLElement;
|
||||
dismiss.click();
|
||||
|
||||
expect(onMarkRead).toHaveBeenCalledWith(item);
|
||||
});
|
||||
|
||||
it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
|
||||
const onMarkAllRead = vi.fn();
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention()],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead
|
||||
}
|
||||
});
|
||||
|
||||
const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
|
||||
btn.click();
|
||||
|
||||
expect(onMarkAllRead).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('builds a deep-link href to the comment for each notification', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
props: {
|
||||
unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
|
||||
onMarkRead: () => {},
|
||||
onMarkAllRead: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
|
||||
expect(link.getAttribute('href')).toContain('doc-x');
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import ChronikRow from './ChronikRow.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseActor = { id: 'a1', name: 'Anna Schmidt', initials: 'AS', color: '#012851' };
|
||||
|
||||
const makeItem = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'i1',
|
||||
kind: 'TEXT_SAVED' as string,
|
||||
actor: baseActor as null | typeof baseActor,
|
||||
documentId: 'd1',
|
||||
documentTitle: 'Brief 1923',
|
||||
count: 1,
|
||||
happenedAt: '2026-04-15T10:00:00Z',
|
||||
happenedAtUntil: null as string | null,
|
||||
commentId: null as string | null,
|
||||
commentPreview: null as string | null,
|
||||
annotationId: null as string | null,
|
||||
youMentioned: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('ChronikRow', () => {
|
||||
it('renders the actor avatar with initials when actor is present', async () => {
|
||||
render(ChronikRow, { props: { item: makeItem() } });
|
||||
|
||||
expect(document.body.textContent).toContain('AS');
|
||||
});
|
||||
|
||||
it('renders the question-mark fallback avatar when actor is null', async () => {
|
||||
render(ChronikRow, { props: { item: makeItem({ actor: null }) } });
|
||||
|
||||
const fallback = document.querySelector('[data-testid="chronik-avatar-fallback"]');
|
||||
expect(fallback).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the for-you marker when youMentioned is true', async () => {
|
||||
render(ChronikRow, { props: { item: makeItem({ youMentioned: true }) } });
|
||||
|
||||
const marker = document.querySelector('[data-testid="chronik-foryou-marker"]');
|
||||
expect(marker).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the for-you data-variant when youMentioned is true', async () => {
|
||||
render(ChronikRow, { props: { item: makeItem({ youMentioned: true }) } });
|
||||
|
||||
const link = document.querySelector('a[data-variant]') as HTMLElement;
|
||||
expect(link.getAttribute('data-variant')).toBe('for-you');
|
||||
});
|
||||
|
||||
it('renders the rollup variant when count > 1', async () => {
|
||||
render(ChronikRow, { props: { item: makeItem({ count: 3 }) } });
|
||||
|
||||
const link = document.querySelector('a[data-variant]') as HTMLElement;
|
||||
expect(link.getAttribute('data-variant')).toBe('rollup');
|
||||
const badge = document.querySelector('[data-testid="chronik-count-badge"]');
|
||||
expect(badge).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the comment variant for COMMENT_ADDED kind', async () => {
|
||||
render(ChronikRow, {
|
||||
props: { item: makeItem({ kind: 'COMMENT_ADDED', commentPreview: 'Tolle Geschichte!' }) }
|
||||
});
|
||||
|
||||
const link = document.querySelector('a[data-variant]') as HTMLElement;
|
||||
expect(link.getAttribute('data-variant')).toBe('comment');
|
||||
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
|
||||
expect(preview?.textContent).toContain('Tolle Geschichte!');
|
||||
});
|
||||
|
||||
it('falls back to ellipsis comment preview when commentPreview is null', async () => {
|
||||
render(ChronikRow, { props: { item: makeItem({ kind: 'COMMENT_ADDED' }) } });
|
||||
|
||||
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
|
||||
expect(preview?.textContent).toContain('…');
|
||||
});
|
||||
|
||||
it('renders the document title in a styled span', async () => {
|
||||
render(ChronikRow, { props: { item: makeItem() } });
|
||||
|
||||
const title = document.querySelector('[data-testid="chronik-doc-title"]');
|
||||
expect(title?.textContent).toBe('Brief 1923');
|
||||
});
|
||||
|
||||
it('uses /documents/{id} as default href', async () => {
|
||||
render(ChronikRow, { props: { item: makeItem() } });
|
||||
|
||||
const link = document.querySelector('a[data-variant]') as HTMLAnchorElement;
|
||||
expect(link.href).toContain('/documents/d1');
|
||||
});
|
||||
|
||||
it('uses comment-deep-link href when commentId is set', async () => {
|
||||
render(ChronikRow, {
|
||||
props: { item: makeItem({ commentId: 'c1', kind: 'COMMENT_ADDED' }) }
|
||||
});
|
||||
|
||||
const link = document.querySelector('a[data-variant]') as HTMLAnchorElement;
|
||||
expect(link.href).toContain('c1');
|
||||
});
|
||||
|
||||
it('renders a time-range label when rollup has happenedAtUntil', async () => {
|
||||
render(ChronikRow, {
|
||||
props: {
|
||||
item: makeItem({
|
||||
count: 5,
|
||||
happenedAt: '2026-04-15T10:00:00Z',
|
||||
happenedAtUntil: '2026-04-15T14:30:00Z'
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// Time range uses U+2013 between two HH:MM strings — check for any colon-bearing time
|
||||
expect(document.body.textContent).toMatch(/\d{2}:\d{2}/);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import ChronikTimeline from './ChronikTimeline.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseActor = { id: 'a1', name: 'Anna Schmidt', initials: 'AS', color: '#012851' };
|
||||
|
||||
const makeItem = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'i1',
|
||||
kind: 'TEXT_SAVED' as string,
|
||||
actor: baseActor,
|
||||
documentId: 'd1',
|
||||
documentTitle: 'Brief 1923',
|
||||
count: 1,
|
||||
happenedAt: new Date().toISOString(),
|
||||
youMentioned: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('ChronikTimeline', () => {
|
||||
it('renders nothing when items is empty', async () => {
|
||||
render(ChronikTimeline, { props: { items: [] } });
|
||||
|
||||
const buckets = document.querySelectorAll('[data-testid^="chronik-bucket-"]');
|
||||
expect(buckets.length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders the today bucket for today items', async () => {
|
||||
const today = new Date();
|
||||
render(ChronikTimeline, {
|
||||
props: { items: [makeItem({ id: 'i1', happenedAt: today.toISOString() })] }
|
||||
});
|
||||
|
||||
const today_bucket = document.querySelector('[data-testid="chronik-bucket-today"]');
|
||||
expect(today_bucket).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the older bucket for old items', async () => {
|
||||
render(ChronikTimeline, {
|
||||
props: { items: [makeItem({ id: 'i1', happenedAt: '2020-01-01T10:00:00Z' })] }
|
||||
});
|
||||
|
||||
const olderBucket = document.querySelector('[data-testid="chronik-bucket-older"]');
|
||||
expect(olderBucket).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders multiple buckets when items span time ranges', async () => {
|
||||
const today = new Date();
|
||||
render(ChronikTimeline, {
|
||||
props: {
|
||||
items: [
|
||||
makeItem({ id: 'i1', kind: 'TEXT_SAVED', happenedAt: today.toISOString() }),
|
||||
makeItem({
|
||||
id: 'i2',
|
||||
kind: 'FILE_UPLOADED',
|
||||
documentId: 'd2',
|
||||
happenedAt: '2020-01-01T10:00:00Z'
|
||||
})
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const buckets = document.querySelectorAll('[data-testid^="chronik-bucket-"]');
|
||||
expect(buckets.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DashboardActivityFeed from './DashboardActivityFeed.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseItem = (overrides: Partial<ActivityFeedItemDTO> = {}): ActivityFeedItemDTO =>
|
||||
({
|
||||
kind: 'TEXT_SAVED',
|
||||
documentId: 'doc-1',
|
||||
documentTitle: 'Brief 1899',
|
||||
actor: {
|
||||
id: 'u-1',
|
||||
name: 'Anna Schmidt',
|
||||
initials: 'AS',
|
||||
color: '#336699'
|
||||
},
|
||||
count: 1,
|
||||
happenedAt: '2026-04-14T14:02:00Z',
|
||||
happenedAtUntil: null,
|
||||
youMentioned: false,
|
||||
...overrides
|
||||
}) as ActivityFeedItemDTO;
|
||||
|
||||
describe('DashboardActivityFeed', () => {
|
||||
it('renders the feed caption and show-all link', async () => {
|
||||
render(DashboardActivityFeed, { props: { feed: [] } });
|
||||
|
||||
await expect.element(page.getByText('Kommentare & Aktivität')).toBeVisible();
|
||||
const link = document.querySelector('a[href="/aktivitaeten"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing in the list when the feed is empty', async () => {
|
||||
render(DashboardActivityFeed, { props: { feed: [] } });
|
||||
|
||||
const lists = document.querySelectorAll('ul');
|
||||
expect(lists.length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders one row per feed item with the actor initials', async () => {
|
||||
render(DashboardActivityFeed, {
|
||||
props: {
|
||||
feed: [baseItem(), baseItem({ documentId: 'doc-2', documentTitle: 'Brief 1900' })]
|
||||
}
|
||||
});
|
||||
|
||||
const items = document.querySelectorAll('li');
|
||||
expect(items.length).toBe(2);
|
||||
expect(document.body.textContent).toContain('AS');
|
||||
});
|
||||
|
||||
it('renders the question-mark badge when no actor is set', async () => {
|
||||
render(DashboardActivityFeed, {
|
||||
props: { feed: [baseItem({ actor: null as unknown as undefined })] }
|
||||
});
|
||||
|
||||
const li = document.querySelector('li');
|
||||
expect(li?.textContent).toContain('?');
|
||||
});
|
||||
|
||||
it('renders the rollup count badge when count > 1', async () => {
|
||||
render(DashboardActivityFeed, {
|
||||
props: { feed: [baseItem({ count: 5 })] }
|
||||
});
|
||||
|
||||
const badge = document.querySelector('[data-testid="feed-rollup-count"]');
|
||||
expect(badge?.textContent?.trim()).toBe('5');
|
||||
});
|
||||
|
||||
it('omits the rollup count badge when count is 1', async () => {
|
||||
render(DashboardActivityFeed, { props: { feed: [baseItem({ count: 1 })] } });
|
||||
|
||||
const badge = document.querySelector('[data-testid="feed-rollup-count"]');
|
||||
expect(badge).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the "für dich" badge when youMentioned is true', async () => {
|
||||
render(DashboardActivityFeed, {
|
||||
props: { feed: [baseItem({ youMentioned: true })] }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/für dich/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('maps the kind enum to a localized verb (TEXT_SAVED)', async () => {
|
||||
render(DashboardActivityFeed, {
|
||||
props: { feed: [baseItem({ kind: 'TEXT_SAVED' as ActivityFeedItemDTO['kind'] })] }
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain('hat Text gespeichert in');
|
||||
});
|
||||
|
||||
it('maps the kind enum to a localized verb (FILE_UPLOADED)', async () => {
|
||||
render(DashboardActivityFeed, {
|
||||
props: { feed: [baseItem({ kind: 'FILE_UPLOADED' as ActivityFeedItemDTO['kind'] })] }
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain('hat eine Datei hochgeladen');
|
||||
});
|
||||
|
||||
it('falls back to the raw kind when no verb is mapped', async () => {
|
||||
render(DashboardActivityFeed, {
|
||||
props: {
|
||||
feed: [baseItem({ kind: 'UNKNOWN_KIND' as unknown as ActivityFeedItemDTO['kind'] })]
|
||||
}
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain('UNKNOWN_KIND');
|
||||
});
|
||||
|
||||
it('renders a rollup time range when happenedAtUntil is set and count > 1', async () => {
|
||||
render(DashboardActivityFeed, {
|
||||
props: {
|
||||
feed: [
|
||||
baseItem({
|
||||
happenedAt: '2026-04-14T14:02:00Z',
|
||||
happenedAtUntil: '2026-04-14T14:32:00Z',
|
||||
count: 3
|
||||
})
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// "14:02–14:32" appears (with the en-dash)
|
||||
expect(document.body.textContent).toMatch(/\d{2}:\d{2}–\d{2}:\d{2}/);
|
||||
});
|
||||
|
||||
it('uses the actor initials as the fallback name when name is null', async () => {
|
||||
render(DashboardActivityFeed, {
|
||||
props: {
|
||||
feed: [
|
||||
baseItem({
|
||||
actor: {
|
||||
id: 'u-2',
|
||||
name: null as unknown as undefined,
|
||||
initials: 'XR',
|
||||
color: '#000'
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const strong = document.querySelector('strong');
|
||||
expect(strong?.textContent).toBe('XR');
|
||||
});
|
||||
|
||||
it('builds the document detail href from documentId', async () => {
|
||||
render(DashboardActivityFeed, {
|
||||
props: { feed: [baseItem({ documentId: 'doc-xyz', documentTitle: 'Brief 1901' })] }
|
||||
});
|
||||
|
||||
const link = document.querySelector('a[href="/documents/doc-xyz"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const sender = { id: 's1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' };
|
||||
const receiver = (id: string, name: string) => ({
|
||||
id,
|
||||
firstName: name.split(' ')[0],
|
||||
lastName: name.split(' ').slice(1).join(' ') || name,
|
||||
displayName: name
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
documentDate: '1923-04-15' as string | null,
|
||||
location: 'Berlin' as string | null,
|
||||
status: 'UPLOADED',
|
||||
sender: null as typeof sender | null,
|
||||
receivers: [] as ReturnType<typeof receiver>[],
|
||||
tags: [] as { id: string; name: string }[],
|
||||
inferredRelationship: null,
|
||||
geschichten: [] as {
|
||||
id: string;
|
||||
title: string;
|
||||
publishedAt?: string;
|
||||
author?: { firstName?: string; lastName?: string; email: string };
|
||||
}[],
|
||||
documentId: 'doc-1',
|
||||
canBlogWrite: false
|
||||
};
|
||||
|
||||
describe('DocumentMetadataDrawer', () => {
|
||||
it('renders the three default section headings', async () => {
|
||||
render(DocumentMetadataDrawer, { props: baseProps });
|
||||
|
||||
await expect.element(page.getByRole('heading', { name: 'Details' })).toBeVisible();
|
||||
await expect.element(page.getByRole('heading', { name: 'Personen' })).toBeVisible();
|
||||
await expect.element(page.getByRole('heading', { name: 'Schlagwörter' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the formatted long date when documentDate is provided', async () => {
|
||||
render(DocumentMetadataDrawer, { props: baseProps });
|
||||
|
||||
// formatDate default ('long') format is "15. April 1923" in de-DE.
|
||||
await expect.element(page.getByText(/1923/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders an em-dash when documentDate is null', async () => {
|
||||
render(DocumentMetadataDrawer, { props: { ...baseProps, documentDate: null } });
|
||||
|
||||
// The dash appears in date AND location AND geschichten — multiple matches expected
|
||||
const dashes = document.querySelectorAll('dd, p');
|
||||
const dashTexts = Array.from(dashes)
|
||||
.map((el) => el.textContent?.trim())
|
||||
.filter((t) => t === '—');
|
||||
expect(dashTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders the no-persons placeholder when sender and receivers are empty', async () => {
|
||||
render(DocumentMetadataDrawer, { props: baseProps });
|
||||
|
||||
await expect.element(page.getByText('Keine Personen zugeordnet')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the sender and inferred relationship label when both are present', async () => {
|
||||
render(DocumentMetadataDrawer, {
|
||||
props: {
|
||||
...baseProps,
|
||||
sender,
|
||||
inferredRelationship: { labelFromA: 'Vater', labelFromB: 'Tochter' }
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the receivers list with up to five visible by default', async () => {
|
||||
const receivers = Array.from({ length: 7 }, (_, i) => receiver(`r${i}`, `Person ${i}`));
|
||||
render(DocumentMetadataDrawer, {
|
||||
props: { ...baseProps, sender, receivers }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Person 0')).toBeVisible();
|
||||
await expect.element(page.getByText('Person 4')).toBeVisible();
|
||||
await expect.element(page.getByText('Person 5')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the +N more button when there are more than five receivers', async () => {
|
||||
const receivers = Array.from({ length: 8 }, (_, i) => receiver(`r${i}`, `Person ${i}`));
|
||||
render(DocumentMetadataDrawer, {
|
||||
props: { ...baseProps, sender, receivers }
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /\+3 weitere/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('expands the receiver list when the +N more button is clicked', async () => {
|
||||
const receivers = Array.from({ length: 8 }, (_, i) => receiver(`r${i}`, `Person ${i}`));
|
||||
render(DocumentMetadataDrawer, {
|
||||
props: { ...baseProps, sender, receivers }
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /\+3 weitere/i }).click();
|
||||
|
||||
await expect.element(page.getByText('Person 7')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the no-tags placeholder when tags is empty', async () => {
|
||||
render(DocumentMetadataDrawer, { props: baseProps });
|
||||
|
||||
await expect.element(page.getByText('Keine Schlagwörter zugeordnet')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders one anchor per tag when tags are present', async () => {
|
||||
render(DocumentMetadataDrawer, {
|
||||
props: {
|
||||
...baseProps,
|
||||
tags: [
|
||||
{ id: 't1', name: 'Familie' },
|
||||
{ id: 't2', name: 'Reise' }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: 'Familie' }))
|
||||
.toHaveAttribute('href', '/?tag=Familie');
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: 'Reise' }))
|
||||
.toHaveAttribute('href', '/?tag=Reise');
|
||||
});
|
||||
|
||||
it('hides the geschichten column when there are no stories and no canBlogWrite', async () => {
|
||||
render(DocumentMetadataDrawer, { props: baseProps });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('heading', { name: 'Geschichten' }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the geschichten column when canBlogWrite is true even with no stories', async () => {
|
||||
render(DocumentMetadataDrawer, { props: { ...baseProps, canBlogWrite: true } });
|
||||
|
||||
await expect.element(page.getByRole('heading', { name: 'Geschichten' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the attach link to the new-geschichte route when canBlogWrite + documentId', async () => {
|
||||
render(DocumentMetadataDrawer, {
|
||||
props: { ...baseProps, canBlogWrite: true, documentId: 'doc-42' }
|
||||
});
|
||||
|
||||
const links = document.querySelectorAll('a[href*="/geschichten/new?documentId="]');
|
||||
expect(links.length).toBe(1);
|
||||
expect((links[0] as HTMLAnchorElement).href).toContain('documentId=doc-42');
|
||||
});
|
||||
|
||||
it('renders the geschichten list when stories are present', async () => {
|
||||
render(DocumentMetadataDrawer, {
|
||||
props: {
|
||||
...baseProps,
|
||||
geschichten: [
|
||||
{
|
||||
id: 'g1',
|
||||
title: 'Reise nach Berlin',
|
||||
publishedAt: '2026-04-15T10:00:00Z',
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@x' }
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('link', { name: /reise nach berlin/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the show-all geschichten link when there are at least three stories', async () => {
|
||||
render(DocumentMetadataDrawer, {
|
||||
props: {
|
||||
...baseProps,
|
||||
geschichten: Array.from({ length: 3 }, (_, i) => ({
|
||||
id: `g${i}`,
|
||||
title: `Geschichte ${i}`,
|
||||
publishedAt: '2026-04-15T10:00:00Z',
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@x' }
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/zeige alle|alle/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the receiver-only inferred relationship pill only when there is exactly one receiver', async () => {
|
||||
render(DocumentMetadataDrawer, {
|
||||
props: {
|
||||
...baseProps,
|
||||
sender,
|
||||
receivers: [receiver('r1', 'Bert Meier')],
|
||||
inferredRelationship: { labelFromA: 'Vater', labelFromB: 'Tochter' }
|
||||
}
|
||||
});
|
||||
|
||||
// Both labels should be visible — Vater for sender, Tochter for the single receiver
|
||||
await expect.element(page.getByText(/vater/i)).toBeVisible();
|
||||
await expect.element(page.getByText(/tochter/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
|
||||
type Props = {
|
||||
canWrite: boolean;
|
||||
isPdf: boolean;
|
||||
transcribeMode: boolean;
|
||||
filePath?: string | null;
|
||||
originalFilename?: string | null;
|
||||
fileUrl: string;
|
||||
};
|
||||
|
||||
let {
|
||||
canWrite,
|
||||
isPdf,
|
||||
transcribeMode = $bindable(),
|
||||
filePath = null,
|
||||
originalFilename = null,
|
||||
fileUrl
|
||||
}: Props = $props();
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
function startTranscribe() {
|
||||
transcribeMode = true;
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div role="group" class="relative" use:clickOutside onclickoutside={() => (mobileMenuOpen = false)}>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
aria-label={m.topbar_more_actions()}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
class="flex h-9 w-9 items-center justify-center rounded border border-line bg-muted transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/View-More-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if mobileMenuOpen}
|
||||
<div
|
||||
role="menu"
|
||||
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
|
||||
>
|
||||
{#if canWrite && isPdf && !transcribeMode}
|
||||
<button
|
||||
onclick={startTranscribe}
|
||||
aria-label={m.transcription_mode_label()}
|
||||
aria-pressed={false}
|
||||
class="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{m.transcription_mode_label()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if filePath}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={originalFilename}
|
||||
onclick={() => (mobileMenuOpen = false)}
|
||||
class="flex items-center gap-2 rounded px-3 py-2 text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
{m.doc_download_title()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,91 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentMobileMenu from './DocumentMobileMenu.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseProps = {
|
||||
canWrite: false,
|
||||
isPdf: false,
|
||||
transcribeMode: false,
|
||||
filePath: null as string | null,
|
||||
originalFilename: 'brief.pdf' as string | null,
|
||||
fileUrl: ''
|
||||
};
|
||||
|
||||
describe('DocumentMobileMenu', () => {
|
||||
it('renders the kebab trigger button with the more-actions aria-label', async () => {
|
||||
render(DocumentMobileMenu, { props: { ...baseProps, filePath: 'docs/x.pdf' } });
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /weitere aktionen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('starts with the dropdown closed (aria-expanded=false)', async () => {
|
||||
render(DocumentMobileMenu, { props: { ...baseProps, filePath: 'docs/x.pdf' } });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /weitere aktionen/i }))
|
||||
.toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('opens the dropdown when the trigger is clicked', async () => {
|
||||
render(DocumentMobileMenu, { props: { ...baseProps, filePath: 'docs/x.pdf' } });
|
||||
|
||||
await page.getByRole('button', { name: /weitere aktionen/i }).click();
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /weitere aktionen/i }))
|
||||
.toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('shows the transcribe action inside the open menu when canWrite, isPdf, and not in transcribe mode', async () => {
|
||||
render(DocumentMobileMenu, {
|
||||
props: { ...baseProps, canWrite: true, isPdf: true, filePath: 'docs/x.pdf' }
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /weitere aktionen/i }).click();
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides the transcribe action when already in transcribeMode', async () => {
|
||||
render(DocumentMobileMenu, {
|
||||
props: {
|
||||
...baseProps,
|
||||
canWrite: true,
|
||||
isPdf: true,
|
||||
transcribeMode: true,
|
||||
filePath: 'docs/x.pdf'
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /weitere aktionen/i }).click();
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /transkribieren/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the download link inside the open menu when filePath is present', async () => {
|
||||
render(DocumentMobileMenu, {
|
||||
props: { ...baseProps, filePath: 'docs/x.pdf', fileUrl: '/api/docs/x' }
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /weitere aktionen/i }).click();
|
||||
|
||||
await expect.element(page.getByRole('link', { name: /herunterladen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits the download link when filePath is null', async () => {
|
||||
render(DocumentMobileMenu, {
|
||||
props: { ...baseProps, canWrite: true, isPdf: true }
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /weitere aktionen/i }).click();
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: /herunterladen/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
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: () => () => {}
|
||||
}));
|
||||
|
||||
const { default: DocumentRow } = await import('./DocumentRow.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const sender = { id: 's1', displayName: 'Anna Schmidt' };
|
||||
const receiver = { id: 'r1', displayName: 'Bert Meier' };
|
||||
|
||||
const makeDoc = (overrides: Record<string, unknown> = {}) => ({
|
||||
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
|
||||
});
|
||||
|
||||
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() } });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 3, name: /brief 1923/i }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('falls back to originalFilename when title is null', async () => {
|
||||
render(DocumentRow, { props: { item: baseItem({ title: null }) } });
|
||||
|
||||
await expect.element(page.getByRole('heading', { level: 3, name: /b\.pdf/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the sender name in the metadata column', async () => {
|
||||
render(DocumentRow, { props: { item: baseItem() } });
|
||||
|
||||
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the unknown placeholder when sender is null', async () => {
|
||||
render(DocumentRow, { props: { item: baseItem({ sender: null }) } });
|
||||
|
||||
const unknownTexts = document.querySelectorAll('.italic');
|
||||
const hasUnknown = Array.from(unknownTexts).some((el) => el.textContent?.includes('Unbekannt'));
|
||||
expect(hasUnknown).toBe(true);
|
||||
});
|
||||
|
||||
it('renders one tag button per document tag', async () => {
|
||||
render(DocumentRow, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
tags: [
|
||||
{ id: 't1', name: 'Familie', color: null },
|
||||
{ id: 't2', name: 'Reise', color: '#ffaabb' }
|
||||
]
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeVisible();
|
||||
await expect.element(page.getByRole('button', { name: 'Reise' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the bulk-select checkbox when canWrite is true', async () => {
|
||||
render(DocumentRow, { props: { item: baseItem(), canWrite: true } });
|
||||
|
||||
const checkbox = document.querySelector('input[type="checkbox"]');
|
||||
expect(checkbox).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the bulk-select checkbox when canWrite is false', async () => {
|
||||
render(DocumentRow, { props: { item: baseItem(), canWrite: false } });
|
||||
|
||||
const checkbox = document.querySelector('input[type="checkbox"]');
|
||||
expect(checkbox).toBeNull();
|
||||
});
|
||||
|
||||
it('renders archive chips when archive metadata is present', async () => {
|
||||
render(DocumentRow, {
|
||||
props: {
|
||||
item: baseItem({ archiveBox: 'Box 1', archiveFolder: 'Mappe A', location: 'Berlin' })
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Box 1')).toBeVisible();
|
||||
await expect.element(page.getByText('Mappe A')).toBeVisible();
|
||||
await expect.element(page.getByText('Berlin')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the snippet when matchData provides a transcriptionSnippet', async () => {
|
||||
render(DocumentRow, {
|
||||
props: {
|
||||
item: {
|
||||
document: makeDoc(),
|
||||
matchData: { transcriptionSnippet: 'Hello world snippet' },
|
||||
completionPercentage: 50,
|
||||
contributors: []
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('search-snippet')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the summary when present', async () => {
|
||||
render(DocumentRow, {
|
||||
props: { item: baseItem({ summary: 'Brief über die Reise nach Berlin' }) }
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('doc-summary')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders an em-dash for missing documentDate', async () => {
|
||||
render(DocumentRow, { props: { item: baseItem({ documentDate: null }) } });
|
||||
|
||||
// Multiple em-dashes possible; just ensure at least one is rendered
|
||||
expect(document.body.textContent).toContain('—');
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentStatusChip from './DocumentStatusChip.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('DocumentStatusChip', () => {
|
||||
it('renders the placeholder label and gray dot for PLACEHOLDER status', async () => {
|
||||
render(DocumentStatusChip, { props: { status: 'PLACEHOLDER' } });
|
||||
|
||||
const dot = await page.getByTitle('Platzhalter').element();
|
||||
expect(dot.classList.contains('bg-gray-400')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the uploaded label and emerald dot for UPLOADED status', async () => {
|
||||
render(DocumentStatusChip, { props: { status: 'UPLOADED' } });
|
||||
|
||||
const dot = await page.getByTitle('Hochgeladen').element();
|
||||
expect(dot.classList.contains('bg-emerald-500')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the transcribed label and blue dot for TRANSCRIBED status', async () => {
|
||||
render(DocumentStatusChip, { props: { status: 'TRANSCRIBED' } });
|
||||
|
||||
const dot = await page.getByTitle('Transkribiert').element();
|
||||
expect(dot.classList.contains('bg-blue-400')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the reviewed label and amber dot for REVIEWED status', async () => {
|
||||
render(DocumentStatusChip, { props: { status: 'REVIEWED' } });
|
||||
|
||||
const dot = await page.getByTitle('Geprüft').element();
|
||||
expect(dot.classList.contains('bg-amber-400')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the archived label and dark emerald dot for ARCHIVED status', async () => {
|
||||
render(DocumentStatusChip, { props: { status: 'ARCHIVED' } });
|
||||
|
||||
const dot = await page.getByTitle('Archiviert').element();
|
||||
expect(dot.classList.contains('bg-emerald-600')).toBe(true);
|
||||
});
|
||||
|
||||
it('exposes the status as both a title tooltip and an aria-label', async () => {
|
||||
render(DocumentStatusChip, { props: { status: 'UPLOADED' } });
|
||||
|
||||
const dot = await page.getByTitle('Hochgeladen').element();
|
||||
expect(dot.getAttribute('aria-label')).toBe('Hochgeladen');
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('DocumentThumbnail', () => {
|
||||
it('renders the supplied thumbnail image when thumbnailUrl is set', async () => {
|
||||
render(DocumentThumbnail, {
|
||||
props: {
|
||||
doc: { id: 'd1', thumbnailUrl: '/api/d1/thumb', contentType: 'application/pdf' }
|
||||
}
|
||||
});
|
||||
|
||||
const img = document.querySelector('img') as HTMLImageElement;
|
||||
expect(img).not.toBeNull();
|
||||
expect(img.src).toContain('/api/d1/thumb');
|
||||
});
|
||||
|
||||
it('renders the placeholder icon when thumbnailUrl is missing', async () => {
|
||||
render(DocumentThumbnail, {
|
||||
props: { doc: { id: 'd1', thumbnailUrl: null, contentType: 'application/pdf' } }
|
||||
});
|
||||
|
||||
const svg = document.querySelector('svg');
|
||||
expect(svg).not.toBeNull();
|
||||
});
|
||||
|
||||
it('uses the small container size by default', async () => {
|
||||
render(DocumentThumbnail, {
|
||||
props: { doc: { id: 'd1', thumbnailUrl: null, contentType: 'application/pdf' } }
|
||||
});
|
||||
|
||||
const container = document.querySelector('.h-\\[84px\\]');
|
||||
expect(container).not.toBeNull();
|
||||
});
|
||||
|
||||
it('uses the large container size when size="lg"', async () => {
|
||||
render(DocumentThumbnail, {
|
||||
props: {
|
||||
doc: { id: 'd1', thumbnailUrl: null, contentType: 'application/pdf' },
|
||||
size: 'lg'
|
||||
}
|
||||
});
|
||||
|
||||
const container = document.querySelector('.h-\\[168px\\]');
|
||||
expect(container).not.toBeNull();
|
||||
});
|
||||
|
||||
it('uses lazy loading attributes on the thumbnail image', async () => {
|
||||
render(DocumentThumbnail, {
|
||||
props: {
|
||||
doc: { id: 'd1', thumbnailUrl: '/api/d1/thumb', contentType: 'application/pdf' }
|
||||
}
|
||||
});
|
||||
|
||||
const img = document.querySelector('img') as HTMLImageElement;
|
||||
expect(img.loading).toBe('lazy');
|
||||
expect(img.decoding).toBe('async');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
import PersonChipRow from '$lib/person/PersonChipRow.svelte';
|
||||
import OverflowPillButton from '$lib/shared/primitives/OverflowPillButton.svelte';
|
||||
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
||||
import DocumentTopBarTitle from './DocumentTopBarTitle.svelte';
|
||||
import DocumentTopBarActions from './DocumentTopBarActions.svelte';
|
||||
import DocumentMobileMenu from './DocumentMobileMenu.svelte';
|
||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
@@ -59,8 +58,93 @@ const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('applicatio
|
||||
const receivers = $derived(doc.receivers ?? []);
|
||||
const extraCount = $derived(Math.max(0, receivers.length - 2));
|
||||
const overflowPersons = $derived(receivers.slice(2));
|
||||
|
||||
const shortDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
||||
const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long') : null);
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
</script>
|
||||
|
||||
{#snippet transcribeBtn(mobile: boolean)}
|
||||
<button
|
||||
onclick={() => {
|
||||
transcribeMode = true;
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
aria-label={m.transcription_mode_label()}
|
||||
aria-pressed={false}
|
||||
class={mobile
|
||||
? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{m.transcription_mode_label()}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet transcribeStopBtn(mobile: boolean)}
|
||||
<button
|
||||
onclick={() => {
|
||||
transcribeMode = false;
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
aria-label={m.transcription_mode_stop()}
|
||||
aria-pressed={true}
|
||||
class={mobile
|
||||
? 'flex w-full items-center gap-2 rounded bg-primary px-3 py-2 text-left text-[16px] text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'}
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{m.transcription_mode_stop()}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet downloadLink(mobile: boolean)}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={doc.originalFilename}
|
||||
onclick={() => {
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
class={mobile
|
||||
? 'flex items-center gap-2 rounded px-3 py-2 text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'hidden rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary md:block'}
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
{#if mobile}{m.doc_download_title()}{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<div data-topbar class="relative z-10 border-b border-line bg-surface shadow-sm">
|
||||
<!-- Main row -->
|
||||
<div class="flex h-[75px] shrink-0 items-center pr-4 xs:h-[88px]">
|
||||
@@ -77,11 +161,20 @@ const overflowPersons = $derived(receivers.slice(2));
|
||||
<div class="mx-2 h-6 w-px shrink-0 bg-line"></div>
|
||||
|
||||
<!-- Title + meta -->
|
||||
<DocumentTopBarTitle
|
||||
title={doc.title}
|
||||
originalFilename={doc.originalFilename}
|
||||
documentDate={doc.documentDate}
|
||||
/>
|
||||
<div class="min-w-0 flex-1 overflow-hidden">
|
||||
<h1
|
||||
class="truncate font-serif text-[18px] leading-tight text-ink lg:text-[20px]"
|
||||
title={doc.title ?? doc.originalFilename ?? ''}
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h1>
|
||||
{#if shortDate}
|
||||
<p class="font-sans text-[16px] text-ink-2">
|
||||
<span class="lg:hidden">{shortDate}</span>
|
||||
<span class="hidden lg:inline">{longDate}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chip row — desktop only, hidden on small screens to make room for buttons -->
|
||||
<div class="mx-3 hidden min-w-0 shrink-0 md:block">
|
||||
@@ -99,9 +192,7 @@ const overflowPersons = $derived(receivers.slice(2));
|
||||
onclick={() => (detailsOpen = !detailsOpen)}
|
||||
aria-expanded={detailsOpen}
|
||||
aria-label={m.doc_details_toggle()}
|
||||
class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded border px-3 py-1 font-sans text-sm font-semibold transition-colors {detailsOpen
|
||||
? 'border-primary bg-primary text-primary-fg'
|
||||
: 'border-line text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded border px-3 py-1 font-sans text-sm font-semibold transition-colors {detailsOpen ? 'border-primary bg-primary text-primary-fg' : 'border-line text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.doc_details_toggle()}
|
||||
<svg
|
||||
@@ -121,26 +212,72 @@ const overflowPersons = $derived(receivers.slice(2));
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex shrink-0 items-center gap-1.5 font-sans">
|
||||
<DocumentTopBarActions
|
||||
documentId={doc.id}
|
||||
canWrite={canWrite}
|
||||
isPdf={!!isPdf}
|
||||
bind:transcribeMode={transcribeMode}
|
||||
filePath={doc.filePath}
|
||||
originalFilename={doc.originalFilename}
|
||||
fileUrl={fileUrl}
|
||||
/>
|
||||
{#if canWrite && isPdf && !transcribeMode}
|
||||
{@render transcribeBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if (canWrite && isPdf) || doc.filePath}
|
||||
<div class="md:hidden">
|
||||
<DocumentMobileMenu
|
||||
canWrite={canWrite}
|
||||
isPdf={!!isPdf}
|
||||
bind:transcribeMode={transcribeMode}
|
||||
filePath={doc.filePath}
|
||||
originalFilename={doc.originalFilename}
|
||||
fileUrl={fileUrl}
|
||||
{#if transcribeMode}
|
||||
{@render transcribeStopBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if canWrite && !transcribeMode}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
aria-label={m.btn_edit()}
|
||||
class="flex items-center gap-1.5 rounded border border-primary bg-transparent px-3 py-1.5 text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<span class="hidden sm:inline">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath && !transcribeMode}
|
||||
{@render downloadLink(false)}
|
||||
{/if}
|
||||
|
||||
<!-- Kebab menu — mobile only, contains actions hidden below md -->
|
||||
{#if (canWrite && isPdf) || doc.filePath}
|
||||
<div
|
||||
role="group"
|
||||
class="relative md:hidden"
|
||||
use:clickOutside
|
||||
onclickoutside={() => (mobileMenuOpen = false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
aria-label={m.topbar_more_actions()}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
class="flex h-9 w-9 items-center justify-center rounded border border-line bg-muted transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/View-More-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if mobileMenuOpen}
|
||||
<div
|
||||
role="menu"
|
||||
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
|
||||
>
|
||||
{#if canWrite && isPdf && !transcribeMode}
|
||||
{@render transcribeBtn(true)}
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath}
|
||||
{@render downloadLink(true)}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentTopBar from './DocumentTopBar.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const sender = { id: 's1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' };
|
||||
const receiver = { id: 'r1', firstName: 'Bert', lastName: 'Meier', displayName: 'Bert Meier' };
|
||||
|
||||
const baseDoc = {
|
||||
id: 'd1',
|
||||
title: 'Brief an Helene',
|
||||
originalFilename: 'brief.pdf',
|
||||
documentDate: '1923-04-15',
|
||||
sender,
|
||||
receivers: [receiver],
|
||||
filePath: null as string | null,
|
||||
contentType: null as string | null,
|
||||
location: null,
|
||||
status: 'UPLOADED',
|
||||
tags: [] as { id: string; name: string }[]
|
||||
};
|
||||
|
||||
const baseProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
doc: baseDoc,
|
||||
canWrite: false,
|
||||
fileUrl: '',
|
||||
transcribeMode: false,
|
||||
inferredRelationship: null,
|
||||
geschichten: [],
|
||||
canBlogWrite: false,
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('DocumentTopBar', () => {
|
||||
it('renders the document title as the main heading', async () => {
|
||||
render(DocumentTopBar, { props: baseProps() });
|
||||
|
||||
await expect.element(page.getByRole('heading', { name: 'Brief an Helene' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('falls back to originalFilename when title is missing', async () => {
|
||||
render(DocumentTopBar, { props: baseProps({ doc: { ...baseDoc, title: null } }) });
|
||||
|
||||
await expect.element(page.getByRole('heading', { name: 'brief.pdf' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the short documentDate when one is present', async () => {
|
||||
render(DocumentTopBar, { props: baseProps() });
|
||||
|
||||
await expect.element(page.getByText('15.04.1923')).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits the date paragraph entirely when documentDate is null', async () => {
|
||||
render(DocumentTopBar, { props: baseProps({ doc: { ...baseDoc, documentDate: null } }) });
|
||||
|
||||
await expect.element(page.getByText(/^\d{2}\.\d{2}\.\d{4}$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the transcribe button when canWrite is false', async () => {
|
||||
render(DocumentTopBar, {
|
||||
props: baseProps({ doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' } })
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /transkribieren/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the transcribe button when contentType is not PDF', async () => {
|
||||
render(DocumentTopBar, {
|
||||
props: baseProps({
|
||||
canWrite: true,
|
||||
doc: { ...baseDoc, filePath: 'x', contentType: 'image/jpeg' }
|
||||
})
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /transkribieren/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the transcribe button when canWrite is true and the file is a PDF', async () => {
|
||||
render(DocumentTopBar, {
|
||||
props: baseProps({
|
||||
canWrite: true,
|
||||
doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' }
|
||||
})
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the stop-transcribe button when transcribeMode is true', async () => {
|
||||
render(DocumentTopBar, {
|
||||
props: baseProps({
|
||||
canWrite: true,
|
||||
transcribeMode: true,
|
||||
doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' }
|
||||
})
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /fertig/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides the edit link when transcribeMode is true', async () => {
|
||||
render(DocumentTopBar, {
|
||||
props: baseProps({
|
||||
canWrite: true,
|
||||
transcribeMode: true,
|
||||
doc: { ...baseDoc, filePath: 'x', contentType: 'application/pdf' }
|
||||
})
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the edit link when canWrite is true and not in transcribeMode', async () => {
|
||||
render(DocumentTopBar, { props: baseProps({ canWrite: true }) });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: /bearbeiten/i }))
|
||||
.toHaveAttribute('href', '/documents/d1/edit');
|
||||
});
|
||||
|
||||
it('does not render the edit link when canWrite is false', async () => {
|
||||
render(DocumentTopBar, { props: baseProps() });
|
||||
|
||||
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the download link when filePath is present and not in transcribe mode', async () => {
|
||||
render(DocumentTopBar, {
|
||||
props: baseProps({ doc: { ...baseDoc, filePath: 'docs/x.pdf' }, fileUrl: '/api/docs/x' })
|
||||
});
|
||||
|
||||
await expect.element(page.getByTitle('Herunterladen')).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not render the download link when filePath is null', async () => {
|
||||
render(DocumentTopBar, { props: baseProps() });
|
||||
|
||||
await expect.element(page.getByTitle('Herunterladen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the metadata drawer when the details toggle is clicked', async () => {
|
||||
render(DocumentTopBar, { props: baseProps() });
|
||||
|
||||
await page.getByRole('button', { name: /^details$/i }).click();
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /^details$/i }))
|
||||
.toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('renders the mobile kebab menu trigger when filePath is present', async () => {
|
||||
render(DocumentTopBar, {
|
||||
props: baseProps({ doc: { ...baseDoc, filePath: 'docs/x.pdf' } })
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /weitere aktionen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not render the mobile kebab menu when there is no filePath and no canWrite/PDF combo', async () => {
|
||||
render(DocumentTopBar, { props: baseProps() });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /weitere aktionen/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the mobile kebab menu when the trigger is clicked', async () => {
|
||||
render(DocumentTopBar, {
|
||||
props: baseProps({ doc: { ...baseDoc, filePath: 'docs/x.pdf' } })
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /weitere aktionen/i }).click();
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /weitere aktionen/i }))
|
||||
.toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('renders the metadata drawer content when detailsOpen is toggled on', async () => {
|
||||
render(DocumentTopBar, { props: baseProps() });
|
||||
|
||||
await page.getByRole('button', { name: /^details$/i }).click();
|
||||
|
||||
const drawer = document.querySelector('[data-topbar] > div:nth-child(2)');
|
||||
expect(drawer).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
canWrite: boolean;
|
||||
isPdf: boolean;
|
||||
transcribeMode: boolean;
|
||||
filePath?: string | null;
|
||||
originalFilename?: string | null;
|
||||
fileUrl: string;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
canWrite,
|
||||
isPdf,
|
||||
transcribeMode = $bindable(),
|
||||
filePath = null,
|
||||
originalFilename = null,
|
||||
fileUrl
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if canWrite && isPdf && !transcribeMode}
|
||||
<button
|
||||
onclick={() => (transcribeMode = true)}
|
||||
aria-label={m.transcription_mode_label()}
|
||||
aria-pressed={false}
|
||||
class="hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{m.transcription_mode_label()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if transcribeMode}
|
||||
<button
|
||||
onclick={() => (transcribeMode = false)}
|
||||
aria-label={m.transcription_mode_stop()}
|
||||
aria-pressed={true}
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
{m.transcription_mode_stop()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if canWrite && !transcribeMode}
|
||||
<a
|
||||
href="/documents/{documentId}/edit"
|
||||
aria-label={m.btn_edit()}
|
||||
class="flex items-center gap-1.5 rounded border border-primary bg-transparent px-3 py-1.5 text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<span class="hidden sm:inline">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if filePath && !transcribeMode}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={originalFilename}
|
||||
class="hidden rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary md:block"
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentTopBarActions from './DocumentTopBarActions.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseProps = {
|
||||
documentId: 'd1',
|
||||
canWrite: false,
|
||||
isPdf: false,
|
||||
transcribeMode: false,
|
||||
filePath: null as string | null,
|
||||
originalFilename: 'brief.pdf' as string | null,
|
||||
fileUrl: ''
|
||||
};
|
||||
|
||||
describe('DocumentTopBarActions', () => {
|
||||
it('renders nothing visible when canWrite is false and no file is present', async () => {
|
||||
render(DocumentTopBarActions, { props: baseProps });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /transkribieren/i }))
|
||||
.not.toBeInTheDocument();
|
||||
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
|
||||
await expect.element(page.getByTitle('Herunterladen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the transcribe button when canWrite, isPdf, and not transcribing', async () => {
|
||||
render(DocumentTopBarActions, {
|
||||
props: { ...baseProps, canWrite: true, isPdf: true, filePath: 'docs/x.pdf' }
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits the transcribe button when not a PDF', async () => {
|
||||
render(DocumentTopBarActions, {
|
||||
props: { ...baseProps, canWrite: true, isPdf: false, filePath: 'docs/x.jpg' }
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /transkribieren/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the stop-transcribe button when transcribeMode is true', async () => {
|
||||
render(DocumentTopBarActions, {
|
||||
props: {
|
||||
...baseProps,
|
||||
canWrite: true,
|
||||
isPdf: true,
|
||||
transcribeMode: true,
|
||||
filePath: 'docs/x.pdf'
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /fertig/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the edit link to the document edit route when canWrite and not transcribing', async () => {
|
||||
render(DocumentTopBarActions, {
|
||||
props: { ...baseProps, canWrite: true, documentId: 'doc-42' }
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: /bearbeiten/i }))
|
||||
.toHaveAttribute('href', '/documents/doc-42/edit');
|
||||
});
|
||||
|
||||
it('hides the edit link when transcribeMode is true', async () => {
|
||||
render(DocumentTopBarActions, {
|
||||
props: { ...baseProps, canWrite: true, transcribeMode: true }
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the download link when filePath is set and not in transcribe mode', async () => {
|
||||
render(DocumentTopBarActions, {
|
||||
props: { ...baseProps, filePath: 'docs/x.pdf', fileUrl: '/api/docs/x' }
|
||||
});
|
||||
|
||||
await expect.element(page.getByTitle('Herunterladen')).toBeVisible();
|
||||
});
|
||||
|
||||
it('hides the download link when transcribeMode is true', async () => {
|
||||
render(DocumentTopBarActions, {
|
||||
props: { ...baseProps, filePath: 'docs/x.pdf', fileUrl: '/api/docs/x', transcribeMode: true }
|
||||
});
|
||||
|
||||
await expect.element(page.getByTitle('Herunterladen')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
|
||||
type Props = {
|
||||
title?: string | null;
|
||||
originalFilename?: string | null;
|
||||
documentDate?: string | null;
|
||||
};
|
||||
|
||||
let { title, originalFilename, documentDate }: Props = $props();
|
||||
|
||||
const displayTitle = $derived(title || originalFilename || '');
|
||||
const shortDate = $derived(documentDate ? formatDate(documentDate, 'short') : null);
|
||||
const longDate = $derived(documentDate ? formatDate(documentDate, 'long') : null);
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-hidden">
|
||||
<h1
|
||||
class="truncate font-serif text-[18px] leading-tight text-ink lg:text-[20px]"
|
||||
title={displayTitle}
|
||||
>
|
||||
{displayTitle}
|
||||
</h1>
|
||||
{#if shortDate}
|
||||
<p class="font-sans text-[16px] text-ink-2">
|
||||
<span class="lg:hidden">{shortDate}</span>
|
||||
<span class="hidden lg:inline">{longDate}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentTopBarTitle from './DocumentTopBarTitle.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseProps = {
|
||||
title: 'Brief an Helene' as string | null,
|
||||
originalFilename: 'brief.pdf' as string | null,
|
||||
documentDate: '1923-04-15' as string | null
|
||||
};
|
||||
|
||||
describe('DocumentTopBarTitle', () => {
|
||||
it('renders the title as a level-1 heading', async () => {
|
||||
render(DocumentTopBarTitle, { props: baseProps });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 1, name: 'Brief an Helene' }))
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
it('falls back to originalFilename when title is null', async () => {
|
||||
render(DocumentTopBarTitle, { props: { ...baseProps, title: null } });
|
||||
|
||||
await expect.element(page.getByRole('heading', { name: 'brief.pdf' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('falls back to originalFilename when title is an empty string', async () => {
|
||||
render(DocumentTopBarTitle, { props: { ...baseProps, title: '' } });
|
||||
|
||||
await expect.element(page.getByRole('heading', { name: 'brief.pdf' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the short date format when a documentDate is supplied', async () => {
|
||||
render(DocumentTopBarTitle, { props: baseProps });
|
||||
|
||||
await expect.element(page.getByText('15.04.1923')).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits the date paragraph entirely when documentDate is null', async () => {
|
||||
render(DocumentTopBarTitle, { props: { ...baseProps, documentDate: null } });
|
||||
|
||||
expect(document.querySelector('p')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the title (not the originalFilename) for the title attribute when title is set', async () => {
|
||||
render(DocumentTopBarTitle, { props: baseProps });
|
||||
|
||||
const heading = (await page
|
||||
.getByRole('heading', { name: 'Brief an Helene' })
|
||||
.element()) as HTMLElement;
|
||||
expect(heading.getAttribute('title')).toBe('Brief an Helene');
|
||||
});
|
||||
|
||||
it('uses the originalFilename for the title attribute when title is null', async () => {
|
||||
render(DocumentTopBarTitle, { props: { ...baseProps, title: null } });
|
||||
|
||||
const heading = (await page
|
||||
.getByRole('heading', { name: 'brief.pdf' })
|
||||
.element()) as HTMLElement;
|
||||
expect(heading.getAttribute('title')).toBe('brief.pdf');
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import DocumentViewer from './DocumentViewer.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseProps = {
|
||||
doc: { id: 'd1', filePath: null, contentType: null, fileHash: null },
|
||||
fileUrl: '',
|
||||
isLoading: false,
|
||||
error: '',
|
||||
transcribeMode: false,
|
||||
blockNumbers: {},
|
||||
annotationReloadKey: 0,
|
||||
activeAnnotationId: null,
|
||||
annotationsDimmed: false,
|
||||
flashAnnotationId: null,
|
||||
onAnnotationClick: () => {}
|
||||
};
|
||||
|
||||
describe('DocumentViewer', () => {
|
||||
it('renders the loading spinner and label when isLoading is true', async () => {
|
||||
render(DocumentViewer, { props: { ...baseProps, isLoading: true } });
|
||||
|
||||
await expect.element(page.getByText('Lade Dokument...')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the error message when error is set', async () => {
|
||||
render(DocumentViewer, { props: { ...baseProps, error: 'Datei nicht verfügbar' } });
|
||||
|
||||
await expect.element(page.getByText('Datei nicht verfügbar')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows the direct-download link in the error state when filePath is present', async () => {
|
||||
render(DocumentViewer, {
|
||||
props: {
|
||||
...baseProps,
|
||||
doc: { ...baseProps.doc, filePath: 'docs/scan.pdf' },
|
||||
error: 'Render failed'
|
||||
}
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: /direkter download/i }))
|
||||
.toHaveAttribute('href', '/api/documents/d1/file');
|
||||
});
|
||||
|
||||
it('omits the direct-download link in the error state when filePath is null', async () => {
|
||||
render(DocumentViewer, { props: { ...baseProps, error: 'Render failed' } });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: /direkter download/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the no-scan placeholder when filePath is null and there is no error', async () => {
|
||||
render(DocumentViewer, { props: baseProps });
|
||||
|
||||
await expect.element(page.getByText('Kein Scan vorhanden')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders an <img> for non-PDF content types when fileUrl is present', async () => {
|
||||
render(DocumentViewer, {
|
||||
props: {
|
||||
...baseProps,
|
||||
doc: { ...baseProps.doc, filePath: 'docs/x.jpg', contentType: 'image/jpeg' },
|
||||
fileUrl: '/api/documents/d1/file'
|
||||
}
|
||||
});
|
||||
|
||||
const img = await page.getByRole('img', { name: /original-scan/i }).element();
|
||||
expect(img.getAttribute('src')).toBe('/api/documents/d1/file');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { navigating } from '$app/state';
|
||||
import { navigating } from '$app/stores';
|
||||
import DashboardNeedsMetadata from './DashboardNeedsMetadata.svelte';
|
||||
import UploadSuccessBanner from './UploadSuccessBanner.svelte';
|
||||
|
||||
@@ -18,7 +18,7 @@ interface Props {
|
||||
|
||||
let { topDocs, totalCount, bannerCount, onBannerClose }: Props = $props();
|
||||
|
||||
const showSkeleton = $derived(!!navigating.type && topDocs.length === 0);
|
||||
const showSkeleton = $derived(!!$navigating && topDocs.length === 0);
|
||||
const showBlock = $derived(topDocs.length > 0 || bannerCount > 0 || showSkeleton);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,23 +2,19 @@ import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
// The store must live in a separate module because vi.mock factories are
|
||||
// hoisted and cannot reference top-level variables defined in this file.
|
||||
import { navigatingStore } from './__mocks__/navigatingStore';
|
||||
import EnrichmentBlock from './EnrichmentBlock.svelte';
|
||||
|
||||
// Hoist the mutable navigation reference so vi.mock's factory (also hoisted)
|
||||
// can read it via a getter. Sync factory, no dynamic import: ADR-012 invariant.
|
||||
const { mockNavigating } = vi.hoisted(() => ({
|
||||
mockNavigating: { type: null as string | null }
|
||||
}));
|
||||
|
||||
vi.mock('$app/state', () => ({
|
||||
get navigating() {
|
||||
return mockNavigating;
|
||||
}
|
||||
}));
|
||||
vi.mock('$app/stores', async () => {
|
||||
const mod = await import('./__mocks__/navigatingStore');
|
||||
return { navigating: mod.navigatingStore };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockNavigating.type = null;
|
||||
navigatingStore.set(null);
|
||||
});
|
||||
|
||||
type Doc = { id: string; title: string; uploadedAt: string };
|
||||
@@ -69,8 +65,8 @@ describe('EnrichmentBlock', () => {
|
||||
await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the skeleton when navigation is active and topDocs is empty', async () => {
|
||||
mockNavigating.type = 'link';
|
||||
it('renders the skeleton when $navigating is active and topDocs is empty', async () => {
|
||||
navigatingStore.set({ type: 'link' });
|
||||
render(EnrichmentBlock, {
|
||||
topDocs: [],
|
||||
totalCount: 0,
|
||||
@@ -80,8 +76,8 @@ describe('EnrichmentBlock', () => {
|
||||
await expect.element(page.getByTestId('enrichment-block-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the skeleton when topDocs is non-empty even during navigation', async () => {
|
||||
mockNavigating.type = 'link';
|
||||
it('does not render the skeleton when topDocs is non-empty even during $navigating', async () => {
|
||||
navigatingStore.set({ type: 'link' });
|
||||
render(EnrichmentBlock, {
|
||||
topDocs: [doc('d1')],
|
||||
totalCount: 1,
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const makeEntry = (id: string, title: string, overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
title,
|
||||
status: 'idle' as 'idle' | 'error',
|
||||
previewUrl: '',
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('FileSwitcherStrip', () => {
|
||||
it('renders the prev and next buttons', async () => {
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'A.pdf')],
|
||||
activeId: 'f1',
|
||||
onSelect: () => {},
|
||||
onRemove: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /vorherige datei/i })).toBeVisible();
|
||||
await expect.element(page.getByRole('button', { name: /nächste datei/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders one chip per file', async () => {
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'A.pdf'), makeEntry('f2', 'B.pdf'), makeEntry('f3', 'C.pdf')],
|
||||
activeId: 'f1',
|
||||
onSelect: () => {},
|
||||
onRemove: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const chips = document.querySelectorAll('[data-chip-id]');
|
||||
expect(chips.length).toBe(3);
|
||||
});
|
||||
|
||||
it('marks the active chip with aria-current=true', async () => {
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
|
||||
activeId: 'f2',
|
||||
onSelect: () => {},
|
||||
onRemove: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const f2 = document.querySelector('[data-chip-id="f2"]') as HTMLElement;
|
||||
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
|
||||
expect(f2.getAttribute('aria-current')).toBe('true');
|
||||
expect(f1.getAttribute('aria-current')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the error indicator on chips with status="error"', async () => {
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'A.pdf', { status: 'error' })],
|
||||
activeId: 'f1',
|
||||
onSelect: () => {},
|
||||
onRemove: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const chip = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
|
||||
expect(chip.getAttribute('data-status')).toBe('error');
|
||||
});
|
||||
|
||||
it('calls onSelect with the chip id when clicked', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
|
||||
activeId: 'f1',
|
||||
onSelect,
|
||||
onRemove: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const f2 = document.querySelector('[data-chip-id="f2"]') as HTMLElement;
|
||||
f2.click();
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('f2');
|
||||
});
|
||||
|
||||
it('calls onRemove when the remove button is clicked', async () => {
|
||||
const onRemove = vi.fn();
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
|
||||
activeId: 'f1',
|
||||
onSelect: () => {},
|
||||
onRemove
|
||||
}
|
||||
});
|
||||
|
||||
const remove = document.querySelector('[data-remove-id="f1"]') as HTMLElement;
|
||||
remove.click();
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith('f1');
|
||||
});
|
||||
|
||||
it('renders the active title in the sr-only announcer', async () => {
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'Ein Brief.pdf'), makeEntry('f2', 'B')],
|
||||
activeId: 'f1',
|
||||
onSelect: () => {},
|
||||
onRemove: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const announcer = document.querySelector('[aria-live="polite"]');
|
||||
expect(announcer?.textContent).toContain('Ein Brief.pdf');
|
||||
});
|
||||
|
||||
it('prev button on a single-file strip is a no-op (active chip stays)', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'A.pdf')],
|
||||
activeId: 'f1',
|
||||
onSelect,
|
||||
onRemove: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /vorherige datei/i }).click();
|
||||
|
||||
// The active chip is still f1 and onSelect was not invoked with a different id.
|
||||
expect(document.querySelector('[data-chip-id="f1"]')?.getAttribute('aria-current')).toBe(
|
||||
'true'
|
||||
);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('next button on a single-file strip is a no-op (active chip stays)', async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'A.pdf')],
|
||||
activeId: 'f1',
|
||||
onSelect,
|
||||
onRemove: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /nächste datei/i }).click();
|
||||
|
||||
expect(document.querySelector('[data-chip-id="f1"]')?.getAttribute('aria-current')).toBe(
|
||||
'true'
|
||||
);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('navigates with ArrowRight key on focused chip', async () => {
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B'), makeEntry('f3', 'C')],
|
||||
activeId: 'f1',
|
||||
onSelect: () => {},
|
||||
onRemove: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
|
||||
f1.focus();
|
||||
f1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement?.getAttribute('data-chip-id')).toBe('f2');
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates with ArrowLeft key on focused chip (wraps around)', async () => {
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
|
||||
activeId: 'f1',
|
||||
onSelect: () => {},
|
||||
onRemove: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
|
||||
f1.focus();
|
||||
f1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// ArrowLeft from index 0 wraps to last (f2).
|
||||
expect(document.activeElement?.getAttribute('data-chip-id')).toBe('f2');
|
||||
});
|
||||
});
|
||||
|
||||
it('ArrowDown is treated as ArrowRight (vertical key alias)', async () => {
|
||||
render(FileSwitcherStrip, {
|
||||
props: {
|
||||
files: [makeEntry('f1', 'A'), makeEntry('f2', 'B')],
|
||||
activeId: 'f1',
|
||||
onSelect: () => {},
|
||||
onRemove: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const f1 = document.querySelector('[data-chip-id="f1"]') as HTMLElement;
|
||||
f1.focus();
|
||||
f1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement?.getAttribute('data-chip-id')).toBe('f2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import ScriptTypeSelect from './ScriptTypeSelect.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ScriptTypeSelect', () => {
|
||||
it('renders the label and select', async () => {
|
||||
render(ScriptTypeSelect, { props: { value: '' } });
|
||||
|
||||
await expect.element(page.getByLabelText(/schrifttyp/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders all four option values', async () => {
|
||||
render(ScriptTypeSelect, { props: { value: '' } });
|
||||
|
||||
const options = document.querySelectorAll('option');
|
||||
const values = Array.from(options).map((o) => (o as HTMLOptionElement).value);
|
||||
expect(values).toEqual(['', 'TYPEWRITER', 'HANDWRITING_LATIN', 'HANDWRITING_KURRENT']);
|
||||
});
|
||||
|
||||
it('marks the placeholder option as disabled', async () => {
|
||||
render(ScriptTypeSelect, { props: { value: '' } });
|
||||
|
||||
const placeholder = document.querySelector('option[value=""]') as HTMLOptionElement;
|
||||
expect(placeholder.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('initialises the select with the supplied value', async () => {
|
||||
render(ScriptTypeSelect, { props: { value: 'TYPEWRITER' } });
|
||||
|
||||
const select = (await page.getByRole('combobox').element()) as HTMLSelectElement;
|
||||
expect(select.value).toBe('TYPEWRITER');
|
||||
});
|
||||
|
||||
it('disables the select when the disabled prop is true', async () => {
|
||||
render(ScriptTypeSelect, { props: { value: '', disabled: true } });
|
||||
|
||||
const select = (await page.getByRole('combobox').element()) as HTMLSelectElement;
|
||||
expect(select.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
filled: [
|
||||
{ month: '1923-01', count: 5 },
|
||||
{ month: '1923-02', count: 1 },
|
||||
{ month: '1923-03', count: 0 }
|
||||
],
|
||||
maxCount: 5,
|
||||
barAreaHeight: 100,
|
||||
isSelected: () => false,
|
||||
isInDragPreview: () => false,
|
||||
isDragging: false,
|
||||
dragWindowLeftPct: 0,
|
||||
dragWindowRightPct: 0,
|
||||
onbarpointerdown: () => {},
|
||||
onbarpointerenter: () => {},
|
||||
onbarclick: () => {},
|
||||
...overrides
|
||||
});
|
||||
|
||||
import TimelineBars from './TimelineBars.svelte';
|
||||
|
||||
describe('TimelineBars', () => {
|
||||
it('renders one bar per filled bucket', async () => {
|
||||
render(TimelineBars, { props: baseProps() });
|
||||
|
||||
const bars = document.querySelectorAll('[data-testid="timeline-bar"]');
|
||||
expect(bars.length).toBe(3);
|
||||
});
|
||||
|
||||
it('uses the singular aria-label when count is 1', async () => {
|
||||
render(TimelineBars, { props: baseProps() });
|
||||
|
||||
const bars = Array.from(
|
||||
document.querySelectorAll('[data-testid="timeline-bar"]')
|
||||
) as HTMLButtonElement[];
|
||||
expect(bars[1].getAttribute('aria-label')).toContain('1 Dokument');
|
||||
});
|
||||
|
||||
it('uses the plural aria-label when count is greater than 1', async () => {
|
||||
render(TimelineBars, { props: baseProps() });
|
||||
|
||||
const bars = Array.from(
|
||||
document.querySelectorAll('[data-testid="timeline-bar"]')
|
||||
) as HTMLButtonElement[];
|
||||
expect(bars[0].getAttribute('aria-label')).toContain('5 Dokumente');
|
||||
});
|
||||
|
||||
it('marks the bar as aria-pressed when isSelected returns true', async () => {
|
||||
render(TimelineBars, {
|
||||
props: baseProps({ isSelected: (label: string) => label === '1923-01' })
|
||||
});
|
||||
|
||||
const bars = Array.from(
|
||||
document.querySelectorAll('[data-testid="timeline-bar"]')
|
||||
) as HTMLButtonElement[];
|
||||
expect(bars[0].getAttribute('aria-pressed')).toBe('true');
|
||||
expect(bars[1].getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('renders the drag window only when isDragging is true', async () => {
|
||||
render(TimelineBars, {
|
||||
props: baseProps({ isDragging: true, dragWindowLeftPct: 10, dragWindowRightPct: 30 })
|
||||
});
|
||||
|
||||
const dragWindow = document.querySelector('[data-testid="timeline-drag-window"]');
|
||||
expect(dragWindow).not.toBeNull();
|
||||
});
|
||||
|
||||
it('omits the drag window when isDragging is false', async () => {
|
||||
render(TimelineBars, { props: baseProps() });
|
||||
|
||||
const dragWindow = document.querySelector('[data-testid="timeline-drag-window"]');
|
||||
expect(dragWindow).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onbarclick with the bucket index when a bar is clicked', async () => {
|
||||
const onbarclick = vi.fn();
|
||||
render(TimelineBars, { props: baseProps({ onbarclick }) });
|
||||
|
||||
const bars = Array.from(
|
||||
document.querySelectorAll('[data-testid="timeline-bar"]')
|
||||
) as HTMLButtonElement[];
|
||||
bars[1].click();
|
||||
|
||||
expect(onbarclick).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('uses minimum bar height for zero-count buckets', async () => {
|
||||
render(TimelineBars, { props: baseProps() });
|
||||
|
||||
const bars = Array.from(
|
||||
document.querySelectorAll('[data-testid="timeline-bar"]')
|
||||
) as HTMLButtonElement[];
|
||||
const zeroBar = bars[2].querySelector('.bar-fill') as HTMLElement;
|
||||
expect(zeroBar.style.height).toContain('2px');
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TimelineControls from './TimelineControls.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('TimelineControls', () => {
|
||||
it('renders neither button when not zoomed and no selection', async () => {
|
||||
render(TimelineControls, {
|
||||
props: {
|
||||
isZoomed: false,
|
||||
hasSelection: false,
|
||||
onresetzoom: () => {},
|
||||
onclearselection: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const buttons = document.querySelectorAll('button');
|
||||
expect(buttons.length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders the reset-zoom button when isZoomed is true', async () => {
|
||||
render(TimelineControls, {
|
||||
props: {
|
||||
isZoomed: true,
|
||||
hasSelection: false,
|
||||
onresetzoom: () => {},
|
||||
onclearselection: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /zur übersicht/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the clear-selection button when hasSelection is true', async () => {
|
||||
render(TimelineControls, {
|
||||
props: {
|
||||
isZoomed: false,
|
||||
hasSelection: true,
|
||||
onresetzoom: () => {},
|
||||
onclearselection: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /auswahl zurücksetzen/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders both buttons when both flags are true', async () => {
|
||||
render(TimelineControls, {
|
||||
props: {
|
||||
isZoomed: true,
|
||||
hasSelection: true,
|
||||
onresetzoom: () => {},
|
||||
onclearselection: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const buttons = document.querySelectorAll('button');
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it('calls onresetzoom when the reset button is clicked', async () => {
|
||||
const onresetzoom = vi.fn();
|
||||
render(TimelineControls, {
|
||||
props: { isZoomed: true, hasSelection: false, onresetzoom, onclearselection: () => {} }
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /zur übersicht/i }).click();
|
||||
|
||||
expect(onresetzoom).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onclearselection when the clear button is clicked', async () => {
|
||||
const onclearselection = vi.fn();
|
||||
render(TimelineControls, {
|
||||
props: { isZoomed: false, hasSelection: true, onresetzoom: () => {}, onclearselection }
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /auswahl zurücksetzen/i }).click();
|
||||
|
||||
expect(onclearselection).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import TimelineXAxis from './TimelineXAxis.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const bucket = (month: string, count = 1) => ({ month, count });
|
||||
|
||||
describe('TimelineXAxis', () => {
|
||||
it('renders no ticks when filled is empty', async () => {
|
||||
render(TimelineXAxis, { props: { filled: [] } });
|
||||
|
||||
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
|
||||
expect(ticks.length).toBe(0);
|
||||
});
|
||||
|
||||
it('renders tick marks when filled buckets are present', async () => {
|
||||
const filled = Array.from({ length: 12 }, (_, i) =>
|
||||
bucket(`1923-${String(i + 1).padStart(2, '0')}`)
|
||||
);
|
||||
render(TimelineXAxis, { props: { filled } });
|
||||
|
||||
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
|
||||
expect(ticks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('omits the year when all visible buckets share the same year', async () => {
|
||||
const filled = Array.from({ length: 12 }, (_, i) =>
|
||||
bucket(`1923-${String(i + 1).padStart(2, '0')}`)
|
||||
);
|
||||
render(TimelineXAxis, { props: { filled } });
|
||||
|
||||
const ticks = Array.from(document.querySelectorAll('[data-testid="timeline-x-tick"]'));
|
||||
const allText = ticks.map((t) => t.textContent ?? '').join(' ');
|
||||
expect(allText).not.toContain('1923');
|
||||
});
|
||||
|
||||
it('shows the year when buckets span multiple years', async () => {
|
||||
const filled = [bucket('1923-01'), bucket('1924-06'), bucket('1925-12')];
|
||||
render(TimelineXAxis, { props: { filled } });
|
||||
|
||||
const ticks = Array.from(document.querySelectorAll('[data-testid="timeline-x-tick"]'));
|
||||
const allText = ticks.map((t) => t.textContent ?? '').join(' ');
|
||||
expect(allText).toMatch(/19\d{2}/);
|
||||
});
|
||||
|
||||
it('handles single-year (length-4) bucket month strings without omitting the year', async () => {
|
||||
const filled = [bucket('1923'), bucket('1924')];
|
||||
render(TimelineXAxis, { props: { filled } });
|
||||
|
||||
const ticks = document.querySelectorAll('[data-testid="timeline-x-tick"]');
|
||||
expect(ticks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import TimelineYAxis from './TimelineYAxis.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('TimelineYAxis', () => {
|
||||
it('renders the maxCount and 0 labels', async () => {
|
||||
render(TimelineYAxis, { props: { maxCount: 42, barAreaHeight: 100 } });
|
||||
|
||||
const axis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
|
||||
expect(axis.textContent).toContain('42');
|
||||
expect(axis.textContent).toContain('0');
|
||||
});
|
||||
|
||||
it('applies the supplied barAreaHeight as inline style', async () => {
|
||||
render(TimelineYAxis, { props: { maxCount: 10, barAreaHeight: 250 } });
|
||||
|
||||
const axis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
|
||||
expect(axis.style.height).toBe('250px');
|
||||
});
|
||||
|
||||
it('renders zero count without crashing', async () => {
|
||||
render(TimelineYAxis, { props: { maxCount: 0, barAreaHeight: 100 } });
|
||||
|
||||
const axis = document.querySelector('[data-testid="timeline-y-axis"]') as HTMLElement;
|
||||
expect(axis).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import UploadZone from './UploadZone.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('UploadZone', () => {
|
||||
describe('idle state', () => {
|
||||
it('shows the filename in the upload zone', async () => {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import WhoWhenSection from './WhoWhenSection.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('WhoWhenSection — date input behavior', () => {
|
||||
it('marks the date input as invalid when input has text but no valid ISO', async () => {
|
||||
render(WhoWhenSection, {});
|
||||
|
||||
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
|
||||
dateInput.value = '32.13';
|
||||
dateInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// Invalid → border-red-400 class
|
||||
expect(dateInput.className).toContain('border-red-400');
|
||||
expect(document.querySelector('#date-error')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show the error before the user has typed', async () => {
|
||||
render(WhoWhenSection, {});
|
||||
|
||||
const error = document.querySelector('#date-error');
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('updates the hidden ISO input when typing a valid German date', async () => {
|
||||
render(WhoWhenSection, {});
|
||||
|
||||
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
|
||||
dateInput.value = '15.03.2024';
|
||||
dateInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const hidden = document.querySelector(
|
||||
'input[name="documentDate"][type="hidden"]'
|
||||
) as HTMLInputElement;
|
||||
expect(hidden.value).toBe('2024-03-15');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the location input outside editMode with initialLocation', async () => {
|
||||
render(WhoWhenSection, { editMode: false, initialLocation: 'Hamburg' });
|
||||
|
||||
const loc = document.querySelector('input#location') as HTMLInputElement;
|
||||
expect(loc.value).toBe('Hamburg');
|
||||
});
|
||||
|
||||
it('hides the location input in editMode', async () => {
|
||||
render(WhoWhenSection, { editMode: true });
|
||||
|
||||
const loc = document.querySelector('input#location');
|
||||
expect(loc).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the FieldLabelBadge for receivers in editMode', async () => {
|
||||
render(WhoWhenSection, { editMode: true });
|
||||
|
||||
// FieldLabelBadge with variant=additive is rendered (just check the heading area)
|
||||
const labels = Array.from(document.querySelectorAll('p, label')).filter((el) =>
|
||||
/empfänger/i.test(el.textContent ?? '')
|
||||
);
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders the date asterisk indicator (required field)', async () => {
|
||||
render(WhoWhenSection, {});
|
||||
|
||||
const label = document.querySelector('label[for="documentDate"]');
|
||||
expect(label?.textContent).toContain('*');
|
||||
});
|
||||
});
|
||||
3
frontend/src/lib/document/__mocks__/navigatingStore.ts
Normal file
3
frontend/src/lib/document/__mocks__/navigatingStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const navigatingStore = writable<unknown | null>(null);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import AnnotationEditOverlay from './AnnotationEditOverlay.svelte';
|
||||
import type { Annotation } from '$lib/shared/types';
|
||||
|
||||
@@ -15,28 +15,17 @@ const annotation: Annotation = {
|
||||
createdAt: '2026-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function getSvg(): SVGSVGElement {
|
||||
const svg = document.querySelector('svg[role="application"]') as SVGSVGElement;
|
||||
if (!svg) throw new Error('no overlay svg');
|
||||
return svg;
|
||||
}
|
||||
|
||||
function makePointerEvent(type: string, init: PointerEventInit = {}): PointerEvent {
|
||||
return new PointerEvent(type, { isPrimary: true, bubbles: true, pointerId: 1, ...init });
|
||||
}
|
||||
|
||||
function makeKeyEvent(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
return new KeyboardEvent('keydown', { key, bubbles: true, ...init });
|
||||
}
|
||||
|
||||
describe('AnnotationEditOverlay — structure', () => {
|
||||
it('renders 8 handle elements (4 corners + 4 edges)', async () => {
|
||||
describe('AnnotationEditOverlay', () => {
|
||||
it('renders 8 handle elements', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const handles = document.querySelectorAll('[data-handle]');
|
||||
expect(handles).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('renders handles for all four corners and four edge midpoints', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
expect(document.querySelector('[data-handle="nw"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-handle="ne"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-handle="sw"]')).not.toBeNull();
|
||||
@@ -47,7 +36,7 @@ describe('AnnotationEditOverlay — structure', () => {
|
||||
expect(document.querySelector('[data-handle="w"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('each handle has a 44×44 hit area', async () => {
|
||||
it('each handle has a 44x44 hit area', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const hitAreas = document.querySelectorAll('[data-handle-hit]');
|
||||
@@ -58,7 +47,7 @@ describe('AnnotationEditOverlay — structure', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a move area covering the full overlay', async () => {
|
||||
it('renders a move area covering the full box', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const moveArea = document.querySelector('[data-move-area]');
|
||||
@@ -68,271 +57,15 @@ describe('AnnotationEditOverlay — structure', () => {
|
||||
it('renders an aria-live region for screen reader announcement', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const live = document.querySelector('[aria-live="polite"]');
|
||||
expect(live).not.toBeNull();
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
expect(liveRegion).not.toBeNull();
|
||||
});
|
||||
|
||||
it('SVG root has tabindex=0 and role=application for keyboard focus', async () => {
|
||||
it('SVG root has tabindex="0" so it can receive keyboard focus', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const svg = getSvg();
|
||||
expect(svg.getAttribute('tabindex')).toBe('0');
|
||||
expect(svg.getAttribute('role')).toBe('application');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnnotationEditOverlay — keyboard navigation', () => {
|
||||
it('moves left on ArrowLeft', async () => {
|
||||
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
|
||||
|
||||
const svg = getSvg();
|
||||
svg.dispatchEvent(makeKeyEvent('ArrowLeft'));
|
||||
// no thrown error — branches reached
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('moves right on ArrowRight', async () => {
|
||||
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
|
||||
|
||||
const svg = getSvg();
|
||||
svg.dispatchEvent(makeKeyEvent('ArrowRight'));
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('moves up on ArrowUp', async () => {
|
||||
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
|
||||
|
||||
const svg = getSvg();
|
||||
svg.dispatchEvent(makeKeyEvent('ArrowUp'));
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('moves down on ArrowDown', async () => {
|
||||
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
|
||||
|
||||
const svg = getSvg();
|
||||
svg.dispatchEvent(makeKeyEvent('ArrowDown'));
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('uses larger step when shiftKey is pressed', async () => {
|
||||
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0.5 } });
|
||||
|
||||
const svg = getSvg();
|
||||
svg.dispatchEvent(makeKeyEvent('ArrowLeft', { shiftKey: true }));
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-arrow keys without preventDefault', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const svg = getSvg();
|
||||
const evt = makeKeyEvent('Enter');
|
||||
svg.dispatchEvent(evt);
|
||||
expect(evt.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps the position at left edge (x=0)', async () => {
|
||||
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0, y: 0.5 } });
|
||||
|
||||
const svg = getSvg();
|
||||
svg.dispatchEvent(makeKeyEvent('ArrowLeft'));
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('clamps the position at top edge (y=0)', async () => {
|
||||
render(AnnotationEditOverlay, { annotation: { ...annotation, x: 0.5, y: 0 } });
|
||||
|
||||
const svg = getSvg();
|
||||
svg.dispatchEvent(makeKeyEvent('ArrowUp'));
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('clamps at right edge so x + width never exceeds 1', async () => {
|
||||
render(AnnotationEditOverlay, {
|
||||
annotation: { ...annotation, x: 0.99, y: 0.5, width: 0.005, height: 0.4 }
|
||||
});
|
||||
|
||||
const svg = getSvg();
|
||||
svg.dispatchEvent(makeKeyEvent('ArrowRight'));
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('clamps at bottom edge so y + height never exceeds 1', async () => {
|
||||
render(AnnotationEditOverlay, {
|
||||
annotation: { ...annotation, x: 0.5, y: 0.99, width: 0.3, height: 0.005 }
|
||||
});
|
||||
|
||||
const svg = getSvg();
|
||||
svg.dispatchEvent(makeKeyEvent('ArrowDown'));
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnnotationEditOverlay — handle keyboard', () => {
|
||||
it('handle <g> exposes role=button so keyboard activates it', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const handle = document.querySelector('[data-handle="nw"]') as SVGGElement;
|
||||
expect(handle.getAttribute('role')).toBe('button');
|
||||
expect(handle.getAttribute('tabindex')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnnotationEditOverlay — pointer drag (move)', () => {
|
||||
it('starts a move drag on pointerdown on the move-area', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const move = document.querySelector('[data-move-area]') as SVGRectElement;
|
||||
// stub setPointerCapture so it doesn't throw without a real capturing implementation
|
||||
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
|
||||
|
||||
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 100, clientY: 100 }));
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-primary pointerdown', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const move = document.querySelector('[data-move-area]') as SVGRectElement;
|
||||
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
|
||||
|
||||
move.dispatchEvent(
|
||||
new PointerEvent('pointerdown', {
|
||||
isPrimary: false,
|
||||
bubbles: true,
|
||||
pointerId: 99,
|
||||
clientX: 0,
|
||||
clientY: 0
|
||||
})
|
||||
);
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('handles pointermove without an active drag (early-return branch)', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const svg = getSvg();
|
||||
svg.dispatchEvent(makePointerEvent('pointermove', { clientX: 0, clientY: 0 }));
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('handles pointerup without an active drag (early-return branch)', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const svg = getSvg();
|
||||
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 0, clientY: 0 }));
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnnotationEditOverlay — pointer drag (handle)', () => {
|
||||
it.each(['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'])(
|
||||
'starts a handle drag from %s without throwing',
|
||||
async (id) => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const handle = document.querySelector(`[data-handle="${id}"]`) as SVGGElement;
|
||||
(handle as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture =
|
||||
vi.fn();
|
||||
|
||||
handle.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
|
||||
|
||||
expect(true).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'])(
|
||||
'completes a full drag cycle (down + move + up) from handle %s',
|
||||
async (id) => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const handle = document.querySelector(`[data-handle="${id}"]`) as SVGGElement;
|
||||
(handle as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture =
|
||||
vi.fn();
|
||||
|
||||
const svg = getSvg();
|
||||
|
||||
handle.dispatchEvent(makePointerEvent('pointerdown', { clientX: 100, clientY: 100 }));
|
||||
svg.dispatchEvent(makePointerEvent('pointermove', { clientX: 110, clientY: 110 }));
|
||||
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 110, clientY: 110 }));
|
||||
|
||||
expect(true).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('completes a move drag (down + move + up) on the move-area', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const move = document.querySelector('[data-move-area]') as SVGRectElement;
|
||||
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
|
||||
|
||||
const svg = getSvg();
|
||||
|
||||
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
|
||||
svg.dispatchEvent(makePointerEvent('pointermove', { clientX: 60, clientY: 60 }));
|
||||
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 60, clientY: 60 }));
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-primary pointermove', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const move = document.querySelector('[data-move-area]') as SVGRectElement;
|
||||
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
|
||||
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
|
||||
|
||||
const svg = getSvg();
|
||||
expect(() =>
|
||||
svg.dispatchEvent(
|
||||
new PointerEvent('pointermove', {
|
||||
isPrimary: false,
|
||||
bubbles: true,
|
||||
pointerId: 99,
|
||||
clientX: 60,
|
||||
clientY: 60
|
||||
})
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('ignores non-primary pointerup', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const move = document.querySelector('[data-move-area]') as SVGRectElement;
|
||||
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
|
||||
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
|
||||
|
||||
const svg = getSvg();
|
||||
expect(() =>
|
||||
svg.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
isPrimary: false,
|
||||
bubbles: true,
|
||||
pointerId: 99,
|
||||
clientX: 60,
|
||||
clientY: 60
|
||||
})
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns early on pointerup without movement (no save)', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const move = document.querySelector('[data-move-area]') as SVGRectElement;
|
||||
(move as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = vi.fn();
|
||||
|
||||
const svg = getSvg();
|
||||
// Down then up at same coords — preDrag values match live values, no-op branch
|
||||
move.dispatchEvent(makePointerEvent('pointerdown', { clientX: 50, clientY: 50 }));
|
||||
svg.dispatchEvent(makePointerEvent('pointerup', { clientX: 50, clientY: 50 }));
|
||||
|
||||
expect(true).toBe(true);
|
||||
const svg = document.querySelector('svg[role="application"]');
|
||||
expect(svg).not.toBeNull();
|
||||
expect(svg!.getAttribute('tabindex')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('AnnotationLayer', () => {
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('annotation-delete-ann-1')).not.toBeInTheDocument();
|
||||
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not show delete button when canDraw is false even if annotation is active', async () => {
|
||||
@@ -120,6 +120,6 @@ describe('AnnotationLayer', () => {
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('annotation-delete-ann-1')).not.toBeInTheDocument();
|
||||
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,212 +157,4 @@ describe('AnnotationLayer', () => {
|
||||
expect(el.classList.contains('annotation-flash')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('container style', () => {
|
||||
it('uses crosshair cursor when canDraw is true', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [],
|
||||
canDraw: true,
|
||||
color: '#00c7b1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const wrapper = document.querySelector('[role="presentation"]') as HTMLElement;
|
||||
expect(wrapper.style.cursor).toContain('crosshair');
|
||||
expect(wrapper.style.touchAction).toBe('none');
|
||||
});
|
||||
|
||||
it('omits crosshair cursor when canDraw is false', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const wrapper = document.querySelector('[role="presentation"]') as HTMLElement;
|
||||
expect(wrapper.style.cursor).not.toContain('crosshair');
|
||||
});
|
||||
});
|
||||
|
||||
describe('annotation pointer hover', () => {
|
||||
it('updates hoveredId on pointerenter and clears on pointerleave', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const ann = document.querySelector('[data-testid="annotation-ann-1"]') as HTMLElement;
|
||||
ann.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
ann.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
// No throw is the assertion
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('renders both annotations with activeAnnotationId set', async () => {
|
||||
const second: Annotation = {
|
||||
...annotation,
|
||||
id: 'ann-other',
|
||||
x: 0.5,
|
||||
y: 0.5
|
||||
};
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation, second],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
activeAnnotationId: 'ann-1',
|
||||
dimmed: false,
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const otherEl = document.querySelector('[data-testid="annotation-ann-other"]');
|
||||
const activeEl = document.querySelector('[data-testid="annotation-ann-1"]');
|
||||
expect(otherEl).not.toBeNull();
|
||||
expect(activeEl).not.toBeNull();
|
||||
});
|
||||
|
||||
it('skips faded styling when dimmed is true (dimmed wins over faded)', async () => {
|
||||
const second: Annotation = { ...annotation, id: 'ann-other' };
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation, second],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
activeAnnotationId: 'ann-1',
|
||||
dimmed: true,
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
// Dimmed mode: badge hidden but renders
|
||||
expect(document.querySelector('[data-testid="annotation-ann-1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders without throwing when canDraw is true (delete button visible)', async () => {
|
||||
expect(() =>
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: true,
|
||||
color: '#00c7b1',
|
||||
onDraw: () => {}
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('renders without throwing when blockNumbers map has entries', async () => {
|
||||
expect(() =>
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
blockNumbers: { 'ann-1': 5 },
|
||||
onDraw: () => {}
|
||||
})
|
||||
).not.toThrow();
|
||||
expect(document.body.textContent).toContain('5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawing pointer flow', () => {
|
||||
it('does not start a draw when canDraw is false', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const wrapper = document.querySelector('[role="presentation"]') as HTMLElement;
|
||||
(wrapper as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture =
|
||||
() => {};
|
||||
|
||||
wrapper.dispatchEvent(
|
||||
new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
pointerId: 1
|
||||
})
|
||||
);
|
||||
|
||||
// No preview rect rendered
|
||||
const preview = wrapper.querySelector('div[style*="border: 2px dashed"]');
|
||||
expect(preview).toBeNull();
|
||||
});
|
||||
|
||||
it('does not start a draw when pointerdown lands on an existing annotation', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: true,
|
||||
color: '#00c7b1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const ann = document.querySelector('[data-testid="annotation-ann-1"]') as HTMLElement;
|
||||
(ann as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = () => {};
|
||||
|
||||
// pointerdown bubbles to the layer; layer should refuse to draw because
|
||||
// closest('[data-annotation]') matches.
|
||||
ann.dispatchEvent(
|
||||
new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
pointerId: 1
|
||||
})
|
||||
);
|
||||
|
||||
const preview = document.querySelector('div[style*="border: 2px dashed"]');
|
||||
expect(preview).toBeNull();
|
||||
});
|
||||
|
||||
it('renders no preview rect when no draw is in progress', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [],
|
||||
canDraw: true,
|
||||
color: '#00c7b1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const preview = document.querySelector('div[style*="border: 2px dashed"]');
|
||||
expect(preview).toBeNull();
|
||||
});
|
||||
|
||||
it('handles pointermove without a started draw (early-return)', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [],
|
||||
canDraw: true,
|
||||
color: '#00c7b1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const wrapper = document.querySelector('[role="presentation"]') as HTMLElement;
|
||||
expect(() =>
|
||||
wrapper.dispatchEvent(
|
||||
new PointerEvent('pointermove', { bubbles: true, clientX: 0, clientY: 0 })
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles pointerup without a started draw (early-return)', async () => {
|
||||
let drawn = false;
|
||||
render(AnnotationLayer, {
|
||||
annotations: [],
|
||||
canDraw: true,
|
||||
color: '#00c7b1',
|
||||
onDraw: () => {
|
||||
drawn = true;
|
||||
}
|
||||
});
|
||||
|
||||
const wrapper = document.querySelector('[role="presentation"]') as HTMLElement;
|
||||
wrapper.dispatchEvent(
|
||||
new PointerEvent('pointerup', { bubbles: true, clientX: 0, clientY: 0 })
|
||||
);
|
||||
|
||||
expect(drawn).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user