Compare commits

..

9 Commits

Author SHA1 Message Date
Marcel
bee309e40c test(e2e): read-only user reads a transcription, no edit affordances (#697)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m27s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
CI happy path: seed a PDF document with a transcription block as admin, then
as the READ_ALL "reader" open it — assert the "Transkription lesen" control,
the read text, a plain "Transkription" header, and the absence of the
Lesen/Bearbeiten tabs (panel cannot switch to edit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:21:39 +02:00
Marcel
cef05abaaa feat(ui): confine read-only users to the transcription read view (#697)
On the document detail page, pass canEdit={canWrite} to the panel header,
guard onModeChange so a reader can never flip to edit, and default panelMode
to 'read' for readers. Thread canAnnotate={canWrite} through DocumentViewer
to PdfViewer so the annotation layer's canDraw (which also gates delete and
resize) is off for readers — they can open and read, but not draw, edit, or
delete. The writer-only OCR status check is also skipped for readers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:19:54 +02:00
Marcel
c5d62bef71 feat(ui): show read-only transcription header without an edit tab (#697)
TranscriptionPanelHeader gains a canEdit prop (default true). Editors keep
the Lesen/Bearbeiten segmented toggle; read-only users get a plain
"Transkription" heading instead of a lone single-option pill, while the
"N Abschnitte" status line stays visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:19:36 +02:00
Marcel
d1b24c905f i18n(transcription): add reader read-label and panel title strings (#697)
transcription_read_label ("Transkription lesen") for the read-only entry
control and transcription_panel_title ("Transkription") for the plain
header readers see instead of the Lesen/Bearbeiten toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:14:46 +02:00
Marcel
4e218dc2b8 chore(api): regenerate Document type with hasTranscription (#697)
Mirrors the new server-computed boolean on the document detail payload so
the frontend can gate the transcription entry control at first paint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:08:30 +02:00
Marcel
a73fddefe3 test(security): lock READ_ALL -> 403 on transcription/annotation writes (#697)
Read-only users will soon be able to open the transcription read view, so
the write endpoints become the real authorization boundary. Explicitly
assert a READ_ALL-only principal is forbidden from create/update/reorder/
review block writes and annotation create/patch (the prior tests only used
a no-authority principal).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:03:39 +02:00
Marcel
1ac2a74f30 feat(document): add server-computed hasTranscription to detail payload (#697)
getDocumentById now populates a transient hasTranscription boolean so the
document detail page can gate the transcription entry control at first
paint (no client store, no full block fetch, no layout shift).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:00:23 +02:00
Marcel
8df7d13368 feat(transcription): expose hasBlocks on TranscriptionBlockQueryService (#697)
Domain-service wrapper over existsByDocumentId so other domains can ask
"does this document have any transcription blocks?" without reaching into
the repository.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:59:42 +02:00
Marcel
96e4d20c03 feat(transcription): add existsByDocumentId block query (#697)
Cheap EXISTS query backing a server-side "has a transcription" signal so
read-only users can be offered the read view at first paint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:59:03 +02:00
389 changed files with 8410 additions and 30520 deletions

View File

@@ -154,9 +154,9 @@ Schedule monthly automated restore tests. If the restore fails, the backup is wo
```
Every alert needs: description, severity, likely cause, resolution steps, escalation path.
3. **Upgrading hardware before profiling**
3. **Upgrading VPS tier before profiling**
```
# "The app feels slow" → order more RAM / a faster CPU
# "The app feels slow" → upgrade from CX32 to CX42
# Actual cause: unindexed query scanning 100k rows
```
Profile with Grafana dashboards first. Most perceived performance issues are application bugs, not resource constraints.
@@ -404,8 +404,8 @@ Hetzner Object Storage (S3-compatible, replaces MinIO in prod)
Prometheus + Loki + Alertmanager
```
### Monthly Cost: ~6 EUR (excl. server)
Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM): see invoice · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
### Monthly Cost: ~23 EUR
CX32 VPS (4 vCPU, 8GB RAM): 17 EUR · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
### Reference Documentation
- Full CI workflow, Gitea vs GitHub differences: `docs/infrastructure/ci-gitea.md`

View File

@@ -72,25 +72,6 @@ VITE_SENTRY_DSN=
# Sentry/GlitchTip auth token for source map upload at build time (optional)
SENTRY_AUTH_TOKEN=
# NL search — Ollama LLM inference
# Leave APP_OLLAMA_BASE_URL empty to disable NL search (safe default for CX32 / CI).
# Set to http://ollama:11434 to enable. Requires CX42 (16 GB RAM) to run alongside OCR.
APP_OLLAMA_BASE_URL=http://ollama:11434
# CPU limit: 4.0 is safe on both CX32 (4 vCPUs) and CX42 (8 vCPUs).
# Raise to 7.5 on CX42 for full throughput.
OLLAMA_CPU_LIMIT=4.0
# Memory limit: requires CX42 (16 GB) to run alongside OCR.
# Reduce or set APP_OLLAMA_BASE_URL= on smaller hosts.
OLLAMA_MEM_LIMIT=8g
# Ollama API key — set on the Ollama service to restrict inference API access on archiv-net.
# Generate with: openssl rand -hex 32
# NOTE: Empirically verified that OLLAMA_API_KEY is NOT enforced in Ollama 0.6.5 or 0.30.6 (ADR-028 §7).
# archiv-net network isolation is the only effective access control. Retained for forward compatibility.
OLLAMA_API_KEY=
# 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

View File

@@ -1,127 +0,0 @@
name: Deploy observability stack
description: >-
Deploy observability configs + secrets to /opt/familienarchiv, validate the
compose config, start the stack, and assert the five healthchecked services
are healthy. Per-environment values arrive as inputs.
inputs:
grafana_admin_password:
description: Grafana admin password (secret)
required: true
grafana_db_password:
description: Read-only grafana_reader DB role password (secret, issue #651)
required: true
glitchtip_secret_key:
description: GlitchTip Django secret key (secret)
required: true
postgres_password:
description: PostgreSQL password for the environment (secret)
required: true
postgres_host:
description: >-
Compose project + service hostname, e.g. archiv-staging-db-1. Derived
from the Compose project name and service name — a project rename
requires updating the caller's value. Plain input, not a secret.
required: true
runs:
using: composite
steps:
- name: Deploy observability configs
shell: bash
# 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).
#
# secrets.* is NOT available inside a composite action, so the values
# arrive as inputs mapped to env: below and are referenced as $VAR in
# the heredoc. The delimiter MUST stay unquoted (<<EOF, not <<'EOF') so
# the shell expands $VAR — a quoted delimiter would write the literal
# string "$GRAFANA_ADMIN_PASSWORD" and `config --quiet` would still pass
# (the var is present, just wrong). Do not stage these into intermediate
# variables either, or Gitea log masking can be lost.
env:
GRAFANA_ADMIN_PASSWORD: ${{ inputs.grafana_admin_password }}
GRAFANA_DB_PASSWORD: ${{ inputs.grafana_db_password }}
GLITCHTIP_SECRET_KEY: ${{ inputs.glitchtip_secret_key }}
POSTGRES_PASSWORD: ${{ inputs.postgres_password }}
POSTGRES_HOST: ${{ inputs.postgres_host }}
run: |
set -euo pipefail
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=$GRAFANA_ADMIN_PASSWORD
GRAFANA_DB_PASSWORD=$GRAFANA_DB_PASSWORD
GLITCHTIP_SECRET_KEY=$GLITCHTIP_SECRET_KEY
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
POSTGRES_HOST=$POSTGRES_HOST
EOF
# Five-key non-empty guard: a bare presence check matches an empty
# `KEY=` line, so assert each key has a value. Fail loudly on any
# missing/empty key rather than starting the stack with broken auth.
for key in GRAFANA_ADMIN_PASSWORD GRAFANA_DB_PASSWORD GLITCHTIP_SECRET_KEY POSTGRES_PASSWORD POSTGRES_HOST; do
grep -Eq "^${key}=.+" /opt/familienarchiv/obs-secrets.env \
|| { echo "::error::obs-secrets.env missing or empty: ${key}"; exit 1; }
done
# chmod 600 MUST be the final operation: the ordering is the security
# property — there is no window where the file is world-readable.
chmod 600 /opt/familienarchiv/obs-secrets.env
- name: Validate observability compose config
shell: bash
# 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. POSTGRES_HOST
# is environment-specific and supplied only by obs-secrets.env — obs.env
# documents it but deliberately does not set a value.
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
shell: bash
# 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.
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
shell: bash
# 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"

View File

@@ -1,41 +0,0 @@
name: Reload Caddy
description: >-
Reload the host Caddy service from a DooD job container via a privileged
sibling container and nsenter. No inputs.
runs:
using: composite
steps:
- name: Reload Caddy
shell: bash
# Apply any committed Caddyfile changes before smoke-testing the
# public surface. Without this step, a Caddyfile edit lands in the
# repo but Caddy keeps serving the previous config until someone
# reloads it manually — the smoke test would then catch a stale
# header or a still-proxied /actuator route rather than confirming
# the current config is live.
#
# The runner executes job steps inside Docker containers (DooD).
# `systemctl` is not present in container images and cannot reach
# the host's systemd directly. We use the Docker socket (mounted
# into every job container via runner-config.yaml) to spin up a
# privileged sibling container in the host PID namespace; nsenter
# then enters the host's namespaces so systemctl talks to the real
# host systemd daemon. No sudoers entry is required — the Docker
# socket already grants root-equivalent host access.
#
# Alpine is used: ~5 MB vs ~70 MB for ubuntu, no unnecessary
# tooling, and the digest is pinned so any upstream change requires
# an explicit bump PR. util-linux (which ships nsenter) is installed
# at run time; apk add takes ~1 s on the warm VPS cache.
#
# `reload` not `restart`: reload sends SIGHUP so Caddy re-reads its
# config in-process without dropping TLS connections. `restart`
# would briefly stop the service, losing in-flight requests.
#
# If Caddy is not running this step fails fast before the smoke test
# issues a misleading "port 443 refused" error.
run: |
docker run --rm --privileged --pid=host \
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'

View File

@@ -1,58 +0,0 @@
name: Smoke test
description: >-
Verify the deployed public surface (login reachable, HSTS pinned,
Permissions-Policy present, /actuator blocked) against a given vhost.
inputs:
host:
description: Public vhost to smoke-test, e.g. staging.raddatz.cloud
required: true
runs:
using: composite
steps:
- name: Smoke test deployed environment
shell: bash
# Healthchecks confirm containers are healthy; they do NOT confirm the
# public surface works. This step catches: Caddy not reloaded, HSTS
# header dropped, /actuator block bypassed.
#
# --resolve pins the public host 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.
#
# --resolve is stored as a Bash array so "${RESOLVE[@]}" expands to two
# separate arguments; a quoted string would pass the flag and its value
# as one token and curl would reject it as an unknown option.
#
# 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.
env:
HOST: ${{ inputs.host }}
run: |
set -e
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
# 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/" \
| 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/" \
| 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" = "404" ] || { echo "::error::expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed"

View File

@@ -108,32 +108,6 @@ jobs:
exit 1
fi
- name: Assert deploy-obs writes obs-secrets.env via an unquoted heredoc (#603)
shell: bash
run: |
# Inside a composite action, secrets arrive as $VAR from env: (secrets.*
# is unavailable there), so the obs-secrets.env heredoc MUST use an
# unquoted delimiter (<<EOF) for $VAR to expand. A quoted delimiter
# (<<'EOF') would write the literal string "$GRAFANA_ADMIN_PASSWORD",
# and the action's five-key non-empty guard would STILL pass (the line
# is present, just wrong). This guard enforces the invariant in CI so a
# future re-quote cannot ship broken obs auth green. See ADR-029 / #603.
action='.gitea/actions/deploy-obs/action.yml'
quoted='obs-secrets\.env\s*<<-?\s*[\x27\x22]'
# Self-test: the regex must catch a quoted delimiter and ignore the unquoted one.
printf "obs-secrets.env <<'EOF'\n" | grep -qP "$quoted" \
|| { echo "FAIL: guard self-test — regex missed the quoted <<'EOF' form"; exit 1; }
printf 'obs-secrets.env <<EOF\n' | grep -qvP "$quoted" \
|| { echo "FAIL: guard self-test — regex wrongly flagged the unquoted <<EOF form"; exit 1; }
# Positive: the unquoted heredoc must be present at all.
grep -qP 'obs-secrets\.env\s*<<-?EOF\b' "$action" \
|| { echo "::error::$action no longer writes obs-secrets.env via an unquoted <<EOF heredoc (ADR-029 / #603)"; exit 1; }
# Negative: never a quoted delimiter on the obs-secrets.env heredoc.
if grep -nP "$quoted" "$action"; then
echo "::error::$action writes obs-secrets.env with a quoted heredoc delimiter — secrets would be written as literal \$VAR strings. Use unquoted <<EOF (ADR-029 / #603)."
exit 1
fi
- name: Run unit and component tests with coverage
shell: bash
run: |

View File

@@ -23,11 +23,6 @@ name: nightly
# - host ports: backend 8081, frontend 3001
# - profile: staging (starts mailpit instead of a real SMTP relay)
#
# The obs-stack deploy, Caddy reload, and smoke test are shared with
# release.yml via the composite actions under .gitea/actions/ (ADR-029).
# actions/checkout MUST stay the first step: a local `uses: ./…` action
# only exists on disk after checkout.
#
# Required Gitea secrets:
# STAGING_POSTGRES_PASSWORD
# STAGING_MINIO_PASSWORD
@@ -60,8 +55,6 @@ jobs:
# for the same repo is within that boundary.
runs-on: ubuntu-latest
steps:
# MUST be first: the composite actions below live under .gitea/actions/
# and only exist on disk once the repo is checked out (ADR-029).
- uses: actions/checkout@v4
- name: Write staging env file
@@ -99,7 +92,6 @@ jobs:
# `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.
# App-compose check (not obs), nightly-only — stays inline.
run: |
set -e
docker compose \
@@ -136,21 +128,150 @@ jobs:
--profile staging \
up -d --wait --remove-orphans
# POSTGRES_HOST is derived from the Compose project name (archiv-staging)
# and service name (db). A project rename requires updating this value.
- uses: ./.gitea/actions/deploy-obs
with:
grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }}
glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }}
postgres_password: ${{ secrets.STAGING_POSTGRES_PASSWORD }}
postgres_host: archiv-staging-db-1
- 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 }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
POSTGRES_HOST=archiv-staging-db-1
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
- uses: ./.gitea/actions/reload-caddy
- 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
- uses: ./.gitea/actions/smoke-test
with:
host: staging.raddatz.cloud
- 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
# repo but Caddy keeps serving the previous config until someone
# reloads it manually — the smoke test would then catch a stale
# header or a still-proxied /actuator route rather than confirming
# the current config is live.
#
# The runner executes job steps inside Docker containers (DooD).
# `systemctl` is not present in container images and cannot reach
# the host's systemd directly. We use the Docker socket (mounted
# into every job container via runner-config.yaml) to spin up a
# privileged sibling container in the host PID namespace; nsenter
# then enters the host's namespaces so systemctl talks to the real
# host systemd daemon. No sudoers entry is required — the Docker
# socket already grants root-equivalent host access.
#
# Alpine is used: ~5 MB vs ~70 MB for ubuntu, no unnecessary
# tooling, and the digest is pinned so any upstream change requires
# an explicit bump PR. util-linux (which ships nsenter) is installed
# at run time; apk add takes ~1 s on the warm VPS cache.
#
# `reload` not `restart`: reload sends SIGHUP so Caddy re-reads its
# config in-process without dropping TLS connections. `restart`
# would briefly stop the service, losing in-flight requests.
#
# If Caddy is not running this step fails fast before the smoke test
# issues a misleading "port 443 refused" error.
run: |
docker run --rm --privileged --pid=host \
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
- name: Smoke test deployed environment
# Healthchecks confirm containers are healthy; they do NOT confirm the
# 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.
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
# 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/" \
| 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/" \
| 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" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed"
- name: Cleanup env file
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011

View File

@@ -23,11 +23,6 @@ name: release
# - host ports: backend 8080, frontend 3000
# - profile: (none) — mailpit is excluded; real SMTP relay is used
#
# The obs-stack deploy, Caddy reload, and smoke test are shared with
# nightly.yml via the composite actions under .gitea/actions/ (ADR-029).
# actions/checkout MUST stay the first step: a local `uses: ./…` action
# only exists on disk after checkout.
#
# Required Gitea secrets:
# PROD_POSTGRES_PASSWORD
# PROD_MINIO_PASSWORD
@@ -58,8 +53,6 @@ jobs:
# advertised label of our single-tenant self-hosted runner.
runs-on: ubuntu-latest
steps:
# MUST be first: the composite actions below live under .gitea/actions/
# and only exist on disk once the repo is checked out (ADR-029).
- uses: actions/checkout@v4
- name: Write production env file
@@ -107,21 +100,117 @@ jobs:
--env-file .env.production \
up -d --wait --remove-orphans
# POSTGRES_HOST is derived from the Compose project name (archiv-production)
# and service name (db). A project rename requires updating this value.
- uses: ./.gitea/actions/deploy-obs
with:
grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }}
glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }}
postgres_password: ${{ secrets.PROD_POSTGRES_PASSWORD }}
postgres_host: archiv-production-db-1
- 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 }}
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
POSTGRES_HOST=archiv-production-db-1
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
- uses: ./.gitea/actions/reload-caddy
- 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
- uses: ./.gitea/actions/smoke-test
with:
host: archiv.raddatz.cloud
- 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
# container reaches the host systemd. Must run after deploy (so the
# latest Caddyfile is on disk) and before the smoke test (so the
# public surface reflects the current config). Alpine with pinned
# digest; reload not restart — see nightly.yml for full rationale.
run: |
docker run --rm --privileged --pid=host \
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
- name: Smoke test deployed environment
# See nightly.yml — same three checks, against the prod vhost.
# --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two
# separate arguments; a quoted string would pass the flag and its value
# as one token and curl would reject it as an unknown option.
# Gateway detection via /proc/net/route — no iproute2 dependency.
# See nightly.yml for the full network topology explanation.
run: |
set -e
HOST="archiv.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
# 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/" \
| 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/" \
| 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" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
echo "All smoke checks passed"
- name: Cleanup env file
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011

View File

@@ -86,8 +86,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
├── filestorage/ FileService (S3/MinIO)
├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService
│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController
├── geschichte/ Geschichte (story) domain
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
├── notification/ Notification domain + SseEmitterRegistry
├── ocr/ OCR domain — OcrService, OcrBatchService, training
@@ -106,15 +105,13 @@ backend/src/main/java/org/raddatz/familienarchiv/
### Domain Model
| Entity | Table | Key relationships |
| ------------- | --------------- | --------------------------------------------------------------------------------------- |
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver |
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
| Entity | Table | Key relationships |
| ----------- | ------------- | ------------------------------------------------------------------------------------- |
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver |
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
@@ -155,7 +152,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
### DTOs
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs)**except the geschichte domain**, where every response is a view (`GeschichteView`/`GeschichteSummary`/`JourneyItemView`) assembled inside the service transaction and entities never cross the controller boundary. See [ADR-036](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — lazy collections + `open-in-view: false` make serialized entities a 500 waiting to happen.
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
@@ -163,7 +160,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`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
**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`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
### Security / Permissions
@@ -197,6 +194,7 @@ frontend/src/routes/
│ ├── [id]/edit/ Person edit form
│ ├── new/ Create person form
│ └── review/ Triage view — confirm/rename/merge/delete provisional persons
├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum)
@@ -271,7 +269,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** 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`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
**LLM reminder:** 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`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
---

View File

@@ -33,8 +33,7 @@ src/main/java/org/raddatz/familienarchiv/
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
├── filestorage/ # FileService (S3/MinIO)
├── geschichte/ # Geschichte (story) domain — GeschichteService, GeschichteQueryService
│ └── journeyitem/ # JourneyItem sub-domain — JourneyItemService, JourneyItemController
├── geschichte/ # Geschichte (story) domain
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
├── notification/ # Notification domain + SseEmitterRegistry
├── ocr/ # OCR domain — OcrService, OcrBatchService, training

View File

@@ -29,17 +29,3 @@ Authorization: Basic Gast_User gast
#GET
GET http://localhost:8080/api/admin/tags
Authorization: Basic admin admin123
### One-time backfill: re-sync already-stale auto-titles (#726)
# RUNBOOK: a one-shot ADMIN maintenance call, NOT part of normal operation. Run it ONCE
# after deploying #726 to clean the existing backlog of stale titles (e.g. a title still
# showing "2028" after the date was corrected to "1928"). It is synchronous and idempotent
# — a second run returns {"count": 0} and writes nothing. Hit the backend DIRECTLY on
# port 8080 (NOT through the SvelteKit proxy) so the sweep can't trip the proxy timeout.
# Returns {"count": <documents rewritten>}.
POST http://localhost:8080/api/admin/backfill-titles
Authorization: Basic admin admin123
### NEGATIV-TEST: ein Nicht-Admin darf den Backfill NICHT auslösen -> 403 Forbidden
POST http://localhost:8080/api/admin/backfill-titles
Authorization: Basic Gast_User gast

View File

@@ -41,27 +41,6 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Force WireMock's ee10 Jetty transitive deps to match Spring Boot's 12.1.8 core -->
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-servlet</artifactId>
<version>12.1.8</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-servlets</artifactId>
<version>12.1.8</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-webapp</artifactId>
<version>12.1.8</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-ee</artifactId>
<version>12.1.8</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
@@ -158,12 +137,6 @@
<artifactId>archunit-junit5</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.wiremock</groupId>
<artifactId>wiremock-jetty12</artifactId>
<version>3.9.2</version>
<scope>test</scope>
</dependency>
<!-- Excel Bearbeitung (Apache POI) -->
<dependency>

View File

@@ -50,30 +50,10 @@ public enum AuditKind {
ADMIN_FORCE_LOGOUT,
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
LOGIN_RATE_LIMITED,
// --- Documents ---
/** Payload: none — the deleted document's id is carried in the documentId column */
DOCUMENT_DELETED,
// --- Reading Journeys (Lesereisen) ---
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */
JOURNEY_ITEM_ADDED,
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
JOURNEY_ITEM_REMOVED,
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
JOURNEY_ITEM_NOTE_UPDATED,
/** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */
JOURNEY_ITEMS_REORDERED;
LOGIN_RATE_LIMITED;
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
JOURNEY_ITEMS_REORDERED
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
);
}

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.document;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -46,7 +47,9 @@ import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentVersionService;
import org.raddatz.familienarchiv.filestorage.FileService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.data.domain.Sort;
import org.springframework.security.core.Authentication;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -135,7 +138,7 @@ public class DocumentController {
// --- METADATA ---
@GetMapping("/{id}")
public Document getDocument(@PathVariable UUID id) {
return documentService.getDocumentDetail(id);
return documentService.getDocumentById(id);
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -168,8 +171,8 @@ public class DocumentController {
@DeleteMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id, Authentication authentication) {
documentService.deleteDocument(id, requireUserId(authentication));
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
documentService.deleteDocument(id);
return ResponseEntity.noContent().build();
}
@@ -313,8 +316,7 @@ public class DocumentController {
@RequestParam(required = false) Boolean undated,
Authentication authentication) {
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
SearchFilters filters = new SearchFilters(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
List<UUID> ids = documentService.findIdsForFilter(filters);
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
@@ -386,9 +388,8 @@ public class DocumentController {
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
// defaults to AND, which matches the frontend default and keeps old clients working.
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
SearchFilters filters = new SearchFilters(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
Pageable pageable = PageRequest.of(page, size);
return ResponseEntity.ok(documentService.searchDocuments(filters, sort, dir, pageable));
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, Boolean.TRUE.equals(undated), pageable));
}
@GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)
@@ -403,7 +404,9 @@ public class DocumentController {
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
DocumentDensityResult result = documentService.getDensity(
new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
return ResponseEntity.ok(result);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
.body(result);
}
// --- TRAINING LABELS ---
@@ -442,6 +445,17 @@ public class DocumentController {
return documentVersionService.getVersion(id, versionId);
}
@GetMapping("/conversation")
public List<Document> getConversation(
@RequestParam UUID senderId,
@RequestParam(required = false) UUID receiverId,
@RequestParam(required = false) LocalDate from,
@RequestParam(required = false) LocalDate to,
@RequestParam(defaultValue = "DESC") String dir) {
Sort sort = Sort.by(Sort.Direction.fromString(dir.toUpperCase()), "documentDate");
return documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
}
private UUID requireUserId(Authentication authentication) {
return SecurityUtils.requireUserId(authentication, userService);
}

View File

@@ -1,11 +0,0 @@
package org.raddatz.familienarchiv.document;
import java.util.UUID;
/**
* Published by DocumentService.deleteDocument inside its @Transactional boundary,
* before documentRepository.deleteById fires. Listeners run synchronously in the
* publisher's thread and transaction via plain @EventListener — this is load-bearing:
* see ADR-038.
*/
public record DocumentDeletingEvent(UUID documentId) {}

View File

@@ -15,6 +15,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -36,13 +37,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@EntityGraph("Document.list")
Page<Document> findAll(Pageable pageable);
// Loader for the relevance fast path: list-item enrichment reads tags after the
// repository call returns, so the fetch shape must match the spec-based findAll
// overloads above. Plain findAllById carries no entity graph and must not feed
// enrichItems — see DocumentService.relevanceSortedPageFromSql.
@EntityGraph("Document.list")
List<Document> findByIdIn(Collection<UUID> ids);
// Findet ein Dokument anhand des ursprünglichen Dateinamens
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
Optional<Document> findByOriginalFilename(String originalFilename);
@@ -64,7 +58,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@EntityGraph("Document.full")
List<Document> findByReceiversId(UUID receiverId);
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
List<Document> findByTags_Id(UUID tagId);
@@ -88,6 +81,32 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
@EntityGraph("Document.full")
@Query("SELECT DISTINCT d FROM Document d " +
"JOIN d.receivers r " +
"WHERE " +
"((d.sender.id = :person1 AND r.id = :person2) " +
" OR " +
" (d.sender.id = :person2 AND r.id = :person1)) " +
"AND d.documentDate BETWEEN :from AND :to")
List<Document> findConversation(
@Param("person1") UUID person1,
@Param("person2") UUID person2,
@Param("from") LocalDate from,
@Param("to") LocalDate to,
Sort sort);
@EntityGraph("Document.full")
@Query("SELECT DISTINCT d FROM Document d " +
"LEFT JOIN d.receivers r " +
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
"AND d.documentDate BETWEEN :from AND :to")
List<Document> findSinglePersonCorrespondence(
@Param("personId") UUID personId,
@Param("from") LocalDate from,
@Param("to") LocalDate to,
Sort sort);
@Query(nativeQuery = true, value = """
SELECT d.id FROM documents d
CROSS JOIN LATERAL (

View File

@@ -28,13 +28,10 @@ import org.raddatz.familienarchiv.ocr.TrainingLabel;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.jpa.domain.Specification;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
@@ -71,7 +68,6 @@ import static org.raddatz.familienarchiv.document.DocumentSpecifications.*;
public class DocumentService {
private final DocumentRepository documentRepository;
private final DocumentTitleFactory documentTitleFactory;
private final PersonService personService;
private final FileService fileService;
private final TagService tagService;
@@ -81,7 +77,6 @@ public class DocumentService {
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final AuditLogQueryService auditLogQueryService;
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
private final ApplicationEventPublisher eventPublisher;
public record StoreResult(Document document, boolean isNew) {}
@@ -142,10 +137,8 @@ public class DocumentService {
* <p>Implementation note: groups in memory rather than via SQL GROUP BY
* because the existing {@link Specification} predicates compose easily
* with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
* well under the 200ms p95 target. The controller sets no explicit
* Cache-Control, so the response is served fresh on every load (issue
* #709) — the recompute is imperceptible and stale month counts after an
* edit would be misleading on an interactive chart.
* well under the 200ms p95 target. Cache-Control: max-age=300 on the
* controller layer absorbs repeated browse loads.
*
* <p>Tracked in issue #481 for re-evaluation when {@code documents > 50k}
* — at that scale move the aggregation into SQL (GROUP BY TO_CHAR(meta_date,
@@ -174,13 +167,11 @@ public class DocumentService {
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
boolean hasFts = ftsIds != null;
// Density and search keep separate filter records (DensityFilters has no
// date/undated fields); adapt to SearchFilters here to reuse buildSearchSpec.
// Date bounds stay null and undated=false — the density path never filters by date.
SearchFilters searchFilters = new SearchFilters(
filters.text(), null, null, filters.sender(), filters.receiver(),
filters.tags(), filters.tagQ(), filters.status(), filters.tagOperator(), false);
Specification<Document> spec = buildSearchSpec(hasFts, ftsIds, searchFilters);
Specification<Document> spec = buildSearchSpec(
hasFts, ftsIds, null, null,
filters.sender(), filters.receiver(),
filters.tags(), filters.tagQ(),
filters.status(), filters.tagOperator(), false);
return documentRepository.findAll(spec).stream()
.map(Document::getDocumentDate)
.filter(Objects::nonNull)
@@ -384,17 +375,10 @@ public class DocumentService {
DocumentStatus statusBefore = doc.getStatus();
// Auto-title sync (#726): capture the machine title from the CURRENTLY-persisted state
// BEFORE any setter runs — the setters below overwrite date/location and applyDatePrecision
// skips nulls, so the old state must be read first. The submitted title is the catalog
// auto-title iff it equals this; only then does it follow date/location forward.
String autoTitleBefore = documentTitleFactory.build(doc);
// 1. Einfache Felder Update
doc.setTitle(resolveTitle(dto.getTitle(), autoTitleBefore, doc, dto));
doc.setTitle(dto.getTitle());
doc.setDocumentDate(dto.getDocumentDate());
applyDatePrecision(doc, dto);
validateDateRange(doc); // guard before any save (updateDocumentTags below persists)
doc.setLocation(dto.getLocation());
doc.setTranscription(dto.getTranscription());
doc.setSummary(dto.getSummary());
@@ -435,11 +419,7 @@ public class DocumentService {
doc.setScriptType(dto.getScriptType());
}
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde).
// NB (#726): this reassigns originalFilename to the uploaded file's name. The title's index
// segment is originalFilename, so after a replace the stored title no longer matches
// build(currentState) and the row is treated as manual — neither save-time nor backfill
// rewrites it. Accepted fail-safe (ADR-031), and autoTitleBefore was already captured above.
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
boolean fileReplaced = newFile != null && !newFile.isEmpty();
if (fileReplaced) {
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
@@ -468,92 +448,21 @@ public class DocumentService {
}
/**
* Decides the title to persist on an edit (#726). The submitted title is the catalog
* auto-title only when it equals {@code autoBefore} (built from the stored state) — an exact
* comparison with no heuristic, relying on the edit form round-tripping the stored title
* verbatim when untouched. A machine title is rebuilt from the new state so a corrected
* date/location flows into it; a hand-written or freshly-typed title is kept verbatim. A blank
* submission is never persisted (title is always present) — it falls back to the rebuilt
* auto-title, which always carries at least the index.
*/
private String resolveTitle(String submitted, String autoBefore, Document doc, DocumentUpdateDTO dto) {
if (submitted == null || submitted.isBlank()) {
return documentTitleFactory.build(projectedState(doc, dto));
}
if (!Objects.equals(submitted, autoBefore)) {
return submitted;
}
return documentTitleFactory.build(projectedState(doc, dto));
}
/**
* The document state the regenerated title is built from. It is composed from the SAME
* resolvers the real setters use — {@code documentDate}/{@code location} overwritten from the
* DTO (a null value clears the field), precision/end/raw resolved skip-null via
* {@link #effectivePrecision}/{@link #effectiveMetaDateEnd}/{@link #effectiveMetaDateRaw} — so
* the projection cannot drift from {@link #updateDocument}. The index ({@code originalFilename})
* is never touched by a metadata edit.
*/
private Document projectedState(Document doc, DocumentUpdateDTO dto) {
return Document.builder()
.originalFilename(doc.getOriginalFilename())
.documentDate(dto.getDocumentDate())
.location(dto.getLocation())
.metaDatePrecision(effectivePrecision(doc, dto))
.metaDateEnd(effectiveMetaDateEnd(doc, dto))
.metaDateRaw(effectiveMetaDateRaw(doc, dto))
.build();
}
/**
* Applies the three date-precision fields skip-null: a null DTO field means "not submitted",
* so the stored value is kept rather than overwritten with null — which would fabricate a
* precision the user never chose, the exact dishonesty #666 exists to prevent. Expressed via
* the shared {@code effective*} resolvers so {@link #projectedState} stays lock-step (writing
* the stored value back when the DTO omits a field is a harmless no-op).
* Applies the three date-precision fields only when the DTO carries them.
* A null field means "not submitted" — overwriting the stored value with null
* would fabricate a precision the user never chose, the exact dishonesty #666
* exists to prevent. A row with a genuinely-unknown precision must keep it when
* an unrelated edit (e.g. a location typo) is saved.
*/
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
doc.setMetaDatePrecision(effectivePrecision(doc, dto));
doc.setMetaDateEnd(effectiveMetaDateEnd(doc, dto));
doc.setMetaDateRaw(effectiveMetaDateRaw(doc, dto));
}
// Skip-null date-field resolution shared by applyDatePrecision (the real setters) and
// projectedState (the title projection) — the single rule keeps them from diverging (#726).
private static DatePrecision effectivePrecision(Document doc, DocumentUpdateDTO dto) {
return dto.getMetaDatePrecision() != null ? dto.getMetaDatePrecision() : doc.getMetaDatePrecision();
}
private static LocalDate effectiveMetaDateEnd(Document doc, DocumentUpdateDTO dto) {
return dto.getMetaDateEnd() != null ? dto.getMetaDateEnd() : doc.getMetaDateEnd();
}
private static String effectiveMetaDateRaw(Document doc, DocumentUpdateDTO dto) {
return dto.getMetaDateRaw() != null ? dto.getMetaDateRaw() : doc.getMetaDateRaw();
}
/**
* Friendly guard for the two V69 date-range CHECK constraints, run before save so a
* user date typo returns a clean 400 INVALID_DATE_RANGE instead of falling through to
* the generic handler (HTTP 500 + Sentry + ERROR log). Validates the post-apply {@code doc}
* state, not the DTO, because precision/end may have been carried over from the stored row
* when the DTO field was null. The DB CHECK remains the backstop; this never weakens it.
*/
private void validateDateRange(Document doc) {
// Mirrors chk_meta_date_end_after_start: end >= start, with null start allowed.
// Use isBefore (equal dates are valid) — never !isAfter, which would contradict the DB's >=.
if (doc.getMetaDatePrecision() == DatePrecision.RANGE
&& doc.getDocumentDate() != null
&& doc.getMetaDateEnd() != null
&& doc.getMetaDateEnd().isBefore(doc.getDocumentDate())) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
"meta_date_end must not be before meta_date");
if (dto.getMetaDatePrecision() != null) {
doc.setMetaDatePrecision(dto.getMetaDatePrecision());
}
// Mirrors chk_meta_date_end_only_for_range. API-only: the edit form clears the
// end field off-RANGE, so this branch closes the same 500 class for direct clients.
if (doc.getMetaDateEnd() != null && doc.getMetaDatePrecision() != DatePrecision.RANGE) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
"meta_date_end is only allowed when meta_date_precision is RANGE");
if (dto.getMetaDateEnd() != null) {
doc.setMetaDateEnd(dto.getMetaDateEnd());
}
if (dto.getMetaDateRaw() != null) {
doc.setMetaDateRaw(dto.getMetaDateRaw());
}
}
@@ -591,15 +500,18 @@ public class DocumentService {
* round-trip.
*/
@Transactional(readOnly = true)
public List<UUID> findIdsForFilter(SearchFilters filters) {
boolean hasText = StringUtils.hasText(filters.text());
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator,
boolean undated) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
if (rankedIds.isEmpty()) return List.of();
}
Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
}
@@ -609,18 +521,23 @@ public class DocumentService {
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
* full-text query returned no rows.
*/
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
boolean useOrLogic = filters.tagOperator() == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(filters.tags());
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
LocalDate from, LocalDate to,
UUID sender, UUID receiver,
List<String> tags, String tagQ,
DocumentStatus status, TagOperator tagOperator,
boolean undated) {
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
return Specification.where(textSpec)
.and(isBetween(filters.from(), filters.to()))
.and(hasSender(filters.sender()))
.and(hasReceiver(filters.receiver()))
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(filters.tagQ()))
.and(hasStatus(filters.status()))
.and(undatedOnly(filters.undated()));
.and(hasTagPartial(tagQ))
.and(hasStatus(status))
.and(undatedOnly(undated));
}
/**
@@ -749,8 +666,8 @@ public class DocumentService {
}
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
public DocumentSearchResult searchDocuments(SearchFilters filters, DocumentSort sort, String dir, Pageable pageable) {
boolean hasText = StringUtils.hasText(filters.text());
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, boolean undated, Pageable pageable) {
boolean hasText = StringUtils.hasText(text);
// Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip
// findAllMatchingIdsByFts entirely (ADR-008). This must run BEFORE any
@@ -760,13 +677,13 @@ public class DocumentService {
// no date/sender/receiver/tag/status filters, and undated documents are valid
// FTS hits already folded into the ranked page, so there is no separate undated
// count to report here.
if (!filters.undated() && isPureTextRelevance(hasText, sort, filters)) {
return relevanceSortedPageFromSql(filters.text(), pageable);
if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
return relevanceSortedPageFromSql(text, pageable);
}
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
// FTS matched nothing → no results and, by definition, no undated matches either.
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
}
@@ -774,32 +691,37 @@ public class DocumentService {
// Global undated count for the current filter (q/tags/sender/receiver/status),
// forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so
// it never collapses to the page slice and never double-counts (issue #668).
long undatedCount = countUndatedForFilter(hasText, rankedIds, filters.withUndated(true));
long undatedCount = countUndatedForFilter(hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
return runSearch(hasText, rankedIds, filters, sort, dir, pageable)
return runSearch(text, hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, sort, dir, tagOperator, undated, pageable)
.withUndatedCount(undatedCount);
}
/**
* Counts every undated document (meta_date IS NULL) matching the active filter,
* across all pages, independent of the undated toggle. The caller passes
* {@code filters.withUndated(true)} so the count tracks q/tags/sender/receiver/status
* regardless of the user's "Nur undatierte" toggle. A {@code from}/{@code to} range
* excludes undated rows by the collision rule (#668), so the count is legitimately 0
* inside a date range.
* across all pages, independent of the undated toggle. Reuses {@link #buildSearchSpec}
* with {@code undated=true} forced so the count tracks q/tags/sender/receiver/status.
* A {@code from}/{@code to} range excludes undated rows by the collision rule (#668),
* so the count is legitimately 0 inside a date range.
*/
private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
Specification<Document> undatedSpec = buildSearchSpec(hasText, ftsIds, filters);
private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
Specification<Document> undatedSpec = buildSearchSpec(
hasText, ftsIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, true);
return documentRepository.count(undatedSpec);
}
/** The original search dispatch — produces the page slice + totals, sans undated count. */
private DocumentSearchResult runSearch(boolean hasText, List<UUID> rankedIds, SearchFilters filters,
DocumentSort sort, String dir, Pageable pageable) {
private DocumentSearchResult runSearch(String text, boolean hasText, List<UUID> rankedIds,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status,
DocumentSort sort, String dir, TagOperator tagOperator,
boolean undated, Pageable pageable) {
// The pure-text RELEVANCE fast path is handled by the caller (searchDocuments)
// before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008).
Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
String text = filters.text();
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
// SENDER and RECEIVER sorts load the full match set and slice in-memory.
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
@@ -833,12 +755,12 @@ public class DocumentService {
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
}
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort, SearchFilters filters) {
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort,
LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status) {
return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
&& filters.from() == null && filters.to() == null
&& filters.sender() == null && filters.receiver() == null
&& (filters.tags() == null || filters.tags().isEmpty())
&& (filters.tagQ() == null || filters.tagQ().isBlank()) && filters.status() == null;
&& from == null && to == null && sender == null && receiver == null
&& (tags == null || tags.isEmpty()) && (tagQ == null || tagQ.isBlank()) && status == null;
}
/**
@@ -853,14 +775,14 @@ public class DocumentService {
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
// Preserve ts_rank order from SQL across the JPA findByIdIn call.
// Preserve ts_rank order from SQL across the JPA findAllById call.
Map<UUID, Integer> rankMap = new HashMap<>();
List<UUID> pageIds = new ArrayList<>();
for (int i = 0; i < ftsPage.hits().size(); i++) {
rankMap.put(ftsPage.hits().get(i).id(), i);
pageIds.add(ftsPage.hits().get(i).id());
}
List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
List<Document> docs = documentRepository.findAllById(pageIds).stream()
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
.toList();
return buildResultPaged(docs, text, pageable, ftsPage.total());
@@ -979,6 +901,22 @@ public class DocumentService {
.orElse("");
}
// 2. SPEZIALITÄT: Der Schriftwechsel
// Findet alle Briefe ZWISCHEN zwei Personen (egal wer Sender/Empfänger war)
public List<Document> getConversation(UUID personA, UUID personB) {
// Fall 1: A schreibt an B
Specification<Document> aToB = Specification.where(hasSender(personA)).and(hasReceiver(personB));
// Fall 2: B schreibt an A
Specification<Document> bToA = Specification.where(hasSender(personB)).and(hasReceiver(personA));
// Wir wollen (A->B) ODER (B->A)
Specification<Document> conversation = aToB.or(bToA);
return documentRepository.findAll(conversation, Sort.by(Sort.Direction.ASC, "documentDate"));
}
@Transactional
public void updateScriptType(UUID documentId, ScriptType scriptType) {
Document doc = getDocumentById(documentId);
@@ -1005,40 +943,6 @@ public class DocumentService {
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
tagService.resolveEffectiveColors(doc.getTags());
return doc;
}
/**
* Lightweight summary lookup for internal use (e.g. journey item append validation).
*
* <p><strong>Security contract — read before calling:</strong>
* <ol>
* <li>This method intentionally bypasses per-document scope checks and
* tag-colour resolution. It must only be invoked after
* {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
* the controller layer, guaranteeing the caller is an authenticated
* author.</li>
* <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
* JOURNEY-type check that fires before this call — so the method is never
* reached for STORY-type Geschichten.</li>
* </ol>
* Under the current single-tenant model every authenticated author shares the
* same document scope, so skipping per-document scope checks is safe.
*/
public Document findSummaryByIdInternal(UUID id) {
return documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
}
/**
* Loads a document for the detail view, additionally flagging whether it has any
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
* existence query only runs for the single-document detail endpoint, not for the
* many internal callers that never read the flag.
*/
@Transactional(readOnly = true)
public Document getDocumentDetail(UUID id) {
Document doc = getDocumentById(id);
doc.setHasTranscription(transcriptionBlockQueryService.hasBlocks(id));
return doc;
}
@@ -1059,26 +963,13 @@ public class DocumentService {
return documentRepository.findByReceiversId(receiverId);
}
public DocumentSearchResult searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable) {
Person person = personService.getById(personId);
Specification<Document> spec = buildPersonSpec(person, from, to);
Page<Document> page = documentRepository.findAll(spec, pageable);
List<DocumentListItem> items = enrichItems(page.getContent(), null);
return DocumentSearchResult.paged(items, pageable, page.getTotalElements());
}
private Specification<Document> buildPersonSpec(Person person, LocalDate from, LocalDate to) {
return (root, query, cb) -> {
if (query != null) query.distinct(true);
var receiversJoin = root.join("receivers", JoinType.LEFT);
var senderPredicate = cb.equal(root.get("sender"), person);
var receiverPredicate = cb.equal(receiversJoin, person);
var personPredicate = cb.or(senderPredicate, receiverPredicate);
var predicates = new ArrayList<>(List.of(personPredicate));
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
return cb.and(predicates.toArray(new Predicate[0]));
};
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
LocalDate dateTo = (to != null) ? to : LocalDate.now();
if (receiverId == null) {
return documentRepository.findSinglePersonCorrespondence(senderId, dateFrom, dateTo, sort);
}
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
}
public long getIncompleteCount() {
@@ -1099,13 +990,11 @@ public class DocumentService {
}
@Transactional
public void deleteDocument(UUID id, UUID actorId) {
public void deleteDocument(UUID id) {
if (!documentRepository.existsById(id)) {
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
}
eventPublisher.publishEvent(new DocumentDeletingEvent(id));
documentRepository.deleteById(id);
auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, actorId, id, null);
}
@Transactional
@@ -1117,43 +1006,6 @@ public class DocumentService {
tagService.delete(tagId);
}
/**
* One-time cleanup of already-stale auto-titles (#726, FR-003). For every document whose
* stored title passes the {@link DocumentTitleBackfillMatcher} overwrite heuristic, rebuilds
* the title from the row's current state and persists it only when it actually changed.
* Idempotent: a second run rebuilds the same value and saves nothing. Hand-written prose is
* left untouched.
*
* <p>Saves via {@code documentRepository.save} directly — it must NOT route through
* {@link #updateDocument} (which versions every write), following the {@link #backfillFileHashes}
* precedent: a mechanical rename must not snapshot the whole corpus into {@code document_versions}.
*
* @return the number of documents whose title was rewritten
*/
@Transactional
public int backfillTitles() {
List<Document> docs = documentRepository.findAll();
int updated = 0;
int skipped = 0;
for (Document doc : docs) {
if (!DocumentTitleBackfillMatcher.isOverwritable(
doc.getTitle(), doc.getOriginalFilename(), doc.getLocation())) {
skipped++;
continue;
}
String rebuilt = documentTitleFactory.build(doc);
if (rebuilt.equals(doc.getTitle())) {
skipped++; // already correct — keep idempotent, no write
continue;
}
doc.setTitle(rebuilt);
documentRepository.save(doc); // direct save, no recordVersion (mechanical rename)
updated++;
}
log.info("Title backfill complete: scanned={} updated={} skipped={}", docs.size(), updated, skipped);
return updated;
}
@Transactional
public int backfillFileHashes() {
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();

View File

@@ -1,101 +0,0 @@
package org.raddatz.familienarchiv.document;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Heuristic overwrite test for the one-time title backfill (#726, FR-004): decides whether a
* STORED title is a machine-generated auto-title (and so may be rebuilt from the row's current
* state) versus hand-written prose (left untouched). Used ONLY by the backfill — save-time
* regeneration uses an exact old-vs-new comparison instead, with no heuristic.
*
* <p>A stored title is overwritable iff, after stripping the literal {@code index} prefix:
* <ol>
* <li>it is exactly {@code {index}}, or</li>
* <li>{@code {index} {dateLabel}} with an optional trailing {@code {location}} segment
* (any location — a present, valid date label is itself strong evidence of a machine
* title), or</li>
* <li>{@code {index} {location}} where the segment equals the document's current location
* (no date label, so the segment must match the known location to be distinguished from
* prose).</li>
* </ol>
*
* <p>Security: the {@code index} is compared <em>literally</em> via {@link String#startsWith}
* (never compiled into a regex) because {@code originalFilename} is user-controlled and may carry
* regex metacharacters — an unquoted pattern would be a ReDoS / regex-injection vector
* (CWE-1333 / CWE-625). The date-label sub-patterns use only bounded, non-nested quantifiers over
* short tokens, so there is no catastrophic backtracking. Fail-closed: any null/blank index or
* structural surprise returns {@code false}.
*/
final class DocumentTitleBackfillMatcher {
private static final String SEPARATOR = " ";
// German month tokens derived from the SAME Locale.GERMAN formatters DocumentTitleFormatter
// uses, so the matcher's accepted spellings cannot drift from what the factory emits (full
// names "Januar"…"Dezember"; abbreviations "Jan."…"Dez." — note May/June/July/März carry no
// period). Pattern.quote each so a "." in an abbreviation is literal, never a wildcard.
private static final String FULL_MONTH = monthAlternation("MMMM");
private static final String ABBR_MONTH = monthAlternation("MMM");
private static final String SEASON = "(?:Frühling|Sommer|Herbst|Winter)";
private static final String YEAR = "\\d{1,4}";
private static final String DAY_NUM = "\\d{1,2}";
// One complete date label, anchored, optionally followed by a free-form trailing location
// segment. Only bounded/non-nested quantifiers over short tokens plus a single trailing
// ".+" → linear, no catastrophic backtracking (FR-004 ReDoS guard).
private static final Pattern DATE_LABEL_WITH_OPTIONAL_LOCATION = Pattern.compile(
"^(?:" + String.join("|",
YEAR, // 1916
"ca\\. " + YEAR, // ca. 1920
FULL_MONTH + " " + YEAR, // Juni 1916
DAY_NUM + "\\. " + FULL_MONTH + " " + YEAR, // 24. Dezember 1943
SEASON + " " + YEAR, // Sommer 1916
"Datum unbekannt",
DAY_NUM + "\\." + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 10.11. Jan. 1917
DAY_NUM + "\\. " + ABBR_MONTH + " " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 30. Jan. 2. Feb. 1917
DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR + " " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 30. Dez. 1916 2. Jan. 1917
DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR, // 10. Jan. 1917 (range end == start)
"ab " + DAY_NUM + "\\. " + ABBR_MONTH + " " + YEAR) // ab 10. Jan. 1917
+ ")(?: .+)?$");
private DocumentTitleBackfillMatcher() {
}
static boolean isOverwritable(String title, String index, String location) {
if (title == null || index == null || index.isBlank()) {
return false; // fail closed
}
if (!title.startsWith(index)) {
return false; // index is matched LITERALLY, never as a regex
}
String tail = title.substring(index.length());
if (tail.isEmpty()) {
return true; // exactly {index}
}
if (!tail.startsWith(SEPARATOR)) {
return false;
}
String body = tail.substring(SEPARATOR.length());
if (DATE_LABEL_WITH_OPTIONAL_LOCATION.matcher(body).matches()) {
return true; // {dateLabel} (+ optional trailing location)
}
// No date label: the lone segment must equal the document's current location to be
// distinguished from hand-written prose.
return location != null && !location.isBlank() && body.equals(location);
}
private static String monthAlternation(String pattern) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern, Locale.GERMAN);
Set<String> tokens = new LinkedHashSet<>();
for (int month = 1; month <= 12; month++) {
tokens.add(formatter.format(LocalDate.of(2000, month, 15)));
}
return tokens.stream().map(Pattern::quote).collect(Collectors.joining("|", "(?:", ")"));
}
}

View File

@@ -1,39 +0,0 @@
package org.raddatz.familienarchiv.document;
import org.springframework.stereotype.Component;
/**
* Single source of truth for the auto-generated document title
* {@code {index} {dateLabel} {location}}.
*
* <p>The {@code document} package owns this formula; {@code importing} consumes it
* (see ADR for issue #726). The leading {@code index} is the document's
* {@code originalFilename}; the date label is the honest German label produced by
* {@link DocumentTitleFormatter} (the Java half of the #666 date-label split); the
* trailing location is the {@code meta_location} verbatim, omitted when blank.
*/
@Component
public class DocumentTitleFactory {
static final String SEPARATOR = " ";
/**
* Composes the auto-title from the document's current state. The date segment is
* dropped for UNKNOWN precision or a null date (the honest "no date" case); the
* location segment is dropped when blank.
*/
public String build(Document doc) {
// originalFilename is NOT NULL in production; guard only so a synthetic/partial entity
// never trips StringBuilder(null) with an opaque NPE.
StringBuilder title = new StringBuilder(doc.getOriginalFilename() == null ? "" : doc.getOriginalFilename());
if (doc.getDocumentDate() != null && doc.getMetaDatePrecision() != DatePrecision.UNKNOWN) {
title.append(SEPARATOR).append(DocumentTitleFormatter.formatTitleDate(
doc.getDocumentDate(), doc.getMetaDatePrecision(),
doc.getMetaDateEnd(), doc.getMetaDateRaw()));
}
if (doc.getLocation() != null && !doc.getLocation().isBlank()) {
title.append(SEPARATOR).append(doc.getLocation());
}
return title.toString();
}
}

View File

@@ -1,40 +0,0 @@
package org.raddatz.familienarchiv.document;
import org.raddatz.familienarchiv.tag.TagOperator;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* The filter predicates honoured by {@link DocumentService#searchDocuments} and
* {@link DocumentService#findIdsForFilter}. Sort, direction, and pagination are
* deliberately excluded — they are not filter predicates, and {@code findIdsForFilter}
* needs none of them; they are passed as separate arguments instead.
*
* Kept as a record so the ten values are passed as one named bundle instead of a
* positional argument list where two UUIDs (sender vs. receiver) or two dates
* (from vs. to) can be swapped by accident at the call site — a transposition that
* compiles cleanly and silently returns the wrong rows.
*
* Sibling of {@link DensityFilters} (= these fields minus from/to/undated); kept
* separate on purpose, so the density call path never reasons about date/undated
* fields it deliberately excludes.
*/
public record SearchFilters(
String text,
LocalDate from,
LocalDate to,
UUID sender,
UUID receiver,
List<String> tags,
String tagQ,
DocumentStatus status,
TagOperator tagOperator,
boolean undated) {
/** Returns a copy with {@code undated} overridden — used by the undated-count path. */
public SearchFilters withUndated(boolean undated) {
return new SearchFilters(text, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
}
}

View File

@@ -78,8 +78,4 @@ public class DomainException extends RuntimeException {
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
}
public static DomainException serviceUnavailable(ErrorCode code, String message) {
return new DomainException(code, HttpStatus.SERVICE_UNAVAILABLE, message);
}
}

View File

@@ -15,10 +15,6 @@ public enum ErrorCode {
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
/** A person's birth date is after their death date. 400 */
BIRTH_AFTER_DEATH,
/** A life date and its precision are incoherent: date present with UNKNOWN precision, or precision set without a date. 400 */
INVALID_DATE_PRECISION,
// --- Documents ---
/** A document with the given ID does not exist. 404 */
DOCUMENT_NOT_FOUND,
@@ -30,8 +26,6 @@ public enum ErrorCode {
FILE_UPLOAD_FAILED,
/** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */
UNSUPPORTED_FILE_TYPE,
/** A RANGE date is invalid: meta_date_end is before meta_date, or an end date is set without RANGE precision. 400 */
INVALID_DATE_RANGE,
// --- Users ---
/** A user with the given ID or username does not exist. 404 */
@@ -126,22 +120,6 @@ public enum ErrorCode {
// --- Geschichten (Stories) ---
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
GESCHICHTE_NOT_FOUND,
/** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */
JOURNEY_ITEM_NOT_FOUND,
/** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */
JOURNEY_ITEM_POSITION_CONFLICT,
/** The journey already has the maximum allowed number of items (100). 400 */
JOURNEY_AT_CAPACITY,
/** The document is already present in this journey — duplicate items are not allowed. 409 */
JOURNEY_DOCUMENT_ALREADY_ADDED,
/** The type of an existing Geschichte cannot be changed via PATCH. 409 */
GESCHICHTE_TYPE_IMMUTABLE,
/** A journey-item note exceeds the maximum length (2000 characters). 400 */
JOURNEY_NOTE_TOO_LONG,
/** A Geschichte title exceeds the maximum length (255 characters — the DB column bound). 400 */
GESCHICHTE_TITLE_TOO_LONG,
/** A JOURNEY intro (body) exceeds the maximum length (4000 characters). 400 */
GESCHICHTE_INTRO_TOO_LONG,
// --- Tags ---
/** A tag with the given ID does not exist. 404 */

View File

@@ -6,7 +6,6 @@ import io.sentry.Sentry;
import jakarta.validation.ConstraintViolationException;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -65,45 +64,6 @@ public class GlobalExceptionHandler {
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason()));
}
/**
* Backstop for any database integrity violation that slips past the explicit upstream
* guards (e.g. a future constraint, or the import path emitting a bad range). Turns it into
* a clean 400 instead of a 500 + Sentry alert. The known date-range cases are caught upstream
* and never reach here; this only catches the unanticipated ones — so it logs the constraint
* NAME at WARN to stay debuggable, without re-leaking SQL and without branching the response
* on it (the response stays generic, which is the non-brittle part).
*/
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(DataIntegrityViolationException ex) {
// Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which
// constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the
// offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault.
String constraint = constraintNameOf(ex);
log.warn("Rejected a request that violated a database integrity constraint: {}", constraint);
if ("uq_journey_items_geschichte_position".equals(constraint)) {
// DEFERRABLE INITIALLY DEFERRED — fires at commit when concurrent appends/reorders collide
return ResponseEntity.status(409)
.body(new ErrorResponse(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT,
"A position conflict was detected — another request modified this journey simultaneously"));
}
return ResponseEntity.badRequest()
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
}
/**
* Returns the offending constraint's name from the cause chain, or {@code "unknown"}.
* Reads only the name (a non-sensitive schema identifier) — never the SQL or the values.
*/
private static String constraintNameOf(Throwable ex) {
for (Throwable t = ex; t != null && t != t.getCause(); t = t.getCause()) {
if (t instanceof org.hibernate.exception.ConstraintViolationException cve
&& cve.getConstraintName() != null) {
return cve.getConstraintName();
}
}
return "unknown";
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
Sentry.captureException(ex);

View File

@@ -5,14 +5,12 @@ import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@@ -42,12 +40,6 @@ public class Geschichte {
@Builder.Default
private GeschichteStatus status = GeschichteStatus.DRAFT;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private GeschichteType type = GeschichteType.STORY;
@ManyToOne
@JoinColumn(name = "author_id")
private AppUser author;
@@ -59,18 +51,12 @@ public class Geschichte {
@Builder.Default
private Set<Person> persons = new HashSet<>();
// LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.md. open-in-view is FALSE
// (application.yaml), so this collection is DEAD at Jackson serialization time unless
// explicitly initialized inside the service transaction. getById() is
// @Transactional(readOnly=true) AND calls getItems().size() to force-init before return.
// list() must NOT serialize items at all — it returns a GeschichteSummary projection.
// This is the first List ("bag") collection on Geschichte — adding a second EAGER/
// fetch-joined List here will throw MultipleBagFetchException at boot.
@OneToMany(mappedBy = "geschichte", cascade = CascadeType.ALL, orphanRemoval = true,
fetch = FetchType.LAZY)
@OrderBy("position ASC")
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "geschichten_documents",
joinColumns = @JoinColumn(name = "geschichte_id"),
inverseJoinColumns = @JoinColumn(name = "document_id"))
@Builder.Default
private List<JourneyItem> items = new ArrayList<>();
private Set<Document> documents = new HashSet<>();
@CreationTimestamp
@Column(updatable = false)

View File

@@ -1,15 +1,12 @@
package org.raddatz.familienarchiv.geschichte;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import org.raddatz.familienarchiv.geschichte.GeschichteService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -17,7 +14,6 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -32,17 +28,12 @@ import java.util.UUID;
public class GeschichteController {
private final GeschichteService geschichteService;
private final JourneyItemService journeyItemService;
@GetMapping
public List<GeschichteSummary> list(
@Parameter(description = "Filter by status. Callers without BLOG_WRITE always receive PUBLISHED results regardless of the value passed. Callers with BLOG_WRITE requesting DRAFT receive only their own unpublished stories.")
public List<Geschichte> list(
@RequestParam(required = false) GeschichteStatus status,
@Parameter(description = "AND-filter: story must include all supplied person IDs.")
@RequestParam(name = "personId", required = false) List<UUID> personIds,
@Parameter(description = "Filter to stories containing this document.")
@RequestParam(required = false) UUID documentId,
@Parameter(description = "Maximum results to return. Values ≤ 0 default to 50. Clamped at 200.")
@RequestParam(required = false, defaultValue = "50") int limit) {
return geschichteService.list(
status,
@@ -52,20 +43,20 @@ public class GeschichteController {
}
@GetMapping("/{id}")
public GeschichteView getById(@PathVariable UUID id) {
return geschichteService.getView(id);
public Geschichte getById(@PathVariable UUID id) {
return geschichteService.getById(id);
}
@PostMapping
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
GeschichteView created = geschichteService.create(dto);
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
Geschichte created = geschichteService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PatchMapping("/{id}")
@RequirePermission(Permission.BLOG_WRITE)
public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
return geschichteService.update(id, dto);
}
@@ -75,45 +66,4 @@ public class GeschichteController {
geschichteService.delete(id);
return ResponseEntity.noContent().build();
}
// ─── JourneyItem CRUD ────────────────────────────────────────────────────
@PostMapping("/{id}/items")
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<JourneyItemView> appendItem(
@PathVariable UUID id,
@RequestBody JourneyItemCreateDTO dto) {
JourneyItemView view = journeyItemService.append(id, dto);
return ResponseEntity.status(HttpStatus.CREATED).body(view);
}
@PatchMapping("/{id}/items/{itemId}")
@RequirePermission(Permission.BLOG_WRITE)
public JourneyItemView updateItemNote(
@PathVariable UUID id,
@PathVariable UUID itemId,
@RequestBody JourneyItemUpdateDTO dto) {
return journeyItemService.updateNote(id, itemId, dto);
}
@DeleteMapping("/{id}/items/{itemId}")
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<Void> deleteItem(
@PathVariable UUID id,
@PathVariable UUID itemId) {
journeyItemService.delete(id, itemId);
return ResponseEntity.noContent().build();
}
@PutMapping("/{id}/items/reorder")
@RequirePermission(Permission.BLOG_WRITE)
@Operation(
summary = "Reorder journey items",
description = "itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request."
)
public List<JourneyItemView> reorderItems(
@PathVariable UUID id,
@RequestBody JourneyReorderDTO dto) {
return journeyItemService.reorder(id, dto);
}
}

View File

@@ -1,29 +0,0 @@
package org.raddatz.familienarchiv.geschichte;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.UUID;
/**
* Thin read-only service owning {@link GeschichteRepository}.
* Exists so that {@code JourneyItemService} can check Geschichte existence
* and load Geschichte instances without holding a direct reference to the
* Geschichte repository (cross-domain repository access is not allowed per
* layering rules).
*/
@Service
@RequiredArgsConstructor
public class GeschichteQueryService {
private final GeschichteRepository geschichteRepository;
public boolean existsById(UUID id) {
return geschichteRepository.existsById(id);
}
public Optional<Geschichte> findById(UUID id) {
return geschichteRepository.findById(id);
}
}

View File

@@ -1,47 +1,12 @@
package org.raddatz.familienarchiv.geschichte;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@Repository
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
/**
* Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false).
*
* <p>Status clamp: callers must pass the effective status (PUBLISHED for readers,
* raw status for BLOG_WRITE users). authorId restricts to own drafts when effective=DRAFT.
*
* <p>Person filter: personCount=0 disables the filter. When personCount>0, the story must
* be associated with ALL person ids in personIds (AND-semantics via counting subquery).
* Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL.
*/
@Query("""
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
g.author AS author, g.publishedAt AS publishedAt, g.updatedAt AS updatedAt, g.body AS body
FROM Geschichte g
WHERE g.status = :effectiveStatus
AND (:authorId IS NULL OR g.author.id = :authorId)
AND (:personCount = 0 OR
(SELECT COUNT(DISTINCT p.id)
FROM Geschichte g2 JOIN g2.persons p
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
AND (:documentId IS NULL OR
EXISTS (SELECT 1 FROM JourneyItem ji
WHERE ji.geschichte = g AND ji.document.id = :documentId))
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
""")
List<GeschichteSummary> findSummaries(
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
@Param("authorId") UUID authorId,
@Param("personIds") Collection<UUID> personIds,
@Param("personCount") long personCount,
@Param("documentId") UUID documentId);
}

View File

@@ -4,23 +4,28 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
@@ -36,7 +41,6 @@ public class GeschichteService {
private final PersonService personService;
private final DocumentService documentService;
private final UserService userService;
private final JourneyItemService journeyItemService;
/**
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
@@ -50,26 +54,12 @@ public class GeschichteService {
private static final int DEFAULT_LIMIT = 50;
private static final int MAX_LIMIT = 200;
/** Sentinel used when {@code personIds} is empty to avoid invalid empty IN() SQL. */
private static final UUID NIL_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
// Matches the geschichten.title VARCHAR(255) column (V58) — the service check
// turns what would be a DB-level 500 into a friendly 400.
static final int MAX_TITLE_LENGTH = 255;
// JOURNEY intros travel the verbatim (unsanitized) write path, so they get the
// same three-layer bound as journey notes: frontend maxlength, this check, and
// the V75 CHECK constraint. STORY bodies are sanitized Tiptap HTML and stay
// unbounded on purpose.
static final int MAX_INTRO_LENGTH = 4000;
// ─── Read API ────────────────────────────────────────────────────────────
public long countPublished() {
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
}
// readOnly = true: lazy collections resolve within the same tx when called from getView()
@Transactional(readOnly = true)
public Geschichte getById(UUID id) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
@@ -82,62 +72,24 @@ public class GeschichteService {
return g;
}
@Transactional(readOnly = true)
public GeschichteView getView(UUID id) {
Geschichte g = getById(id);
List<JourneyItemView> items = journeyItemService.getItems(id);
return toView(g, items);
}
GeschichteView toView(Geschichte g, List<JourneyItemView> items) {
AppUser author = g.getAuthor();
GeschichteView.AuthorView authorView = null;
if (author != null) {
String displayName = PersonNameFormatter.join(author.getFirstName(), author.getLastName());
if (displayName.isBlank()) displayName = "[Unbekannt]";
authorView = new GeschichteView.AuthorView(author.getId(), displayName);
}
Set<GeschichteView.PersonView> personViews = new HashSet<>();
for (Person p : g.getPersons()) {
personViews.add(new GeschichteView.PersonView(p.getId(), p.getFirstName(), p.getLastName()));
}
return new GeschichteView(
g.getId(), g.getTitle(), g.getBody(),
g.getStatus(), g.getType(),
authorView, personViews,
items,
g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt()
);
}
/**
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
* must be associated with every person id supplied. An empty or null list applies no
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
*
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
* LazyInitializationException on the non-transactional list path.
*
* <p>Security: {@code null} status always resolves to PUBLISHED — even for blog writers.
* Only an explicit {@code DRAFT} request scopes the query to the caller's own drafts.
* This prevents CWE-639: a blog writer passing {@code null} must not see all authors' drafts.
*/
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
boolean isDraftRequest = currentUserHasBlogWrite() && status == GeschichteStatus.DRAFT;
GeschichteStatus effective = isDraftRequest ? GeschichteStatus.DRAFT : GeschichteStatus.PUBLISHED;
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
UUID authorId = isDraftRequest ? currentUser().getId() : null;
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
? List.of(NIL_UUID)
: personIds;
long personCount = (personIds == null) ? 0 : personIds.size();
return geschichteRepository
.findSummaries(effective, authorId, safePersonIds, personCount, documentId)
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
Specification<Geschichte> spec = Specification.allOf(
GeschichteSpecifications.hasStatus(effective),
GeschichteSpecifications.hasAuthor(authorId),
GeschichteSpecifications.hasAllPersons(personIds),
GeschichteSpecifications.hasDocument(documentId),
GeschichteSpecifications.orderByDisplayDateDesc()
);
return geschichteRepository.findAll(spec, Sort.unsorted())
.stream()
.limit(safeLimit)
.toList();
@@ -145,57 +97,46 @@ public class GeschichteService {
// ─── Write API ───────────────────────────────────────────────────────────
// Write methods return GeschichteView, never the entity: Jackson serializes after
// the transaction closed, where the lazy items collection is a dead proxy.
// The view is assembled in-transaction, so no force-init tricks are needed.
@Transactional
public GeschichteView create(GeschichteUpdateDTO dto) {
public Geschichte create(GeschichteUpdateDTO dto) {
requireTitle(dto.getTitle());
GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
Geschichte g = Geschichte.builder()
.title(dto.getTitle().trim())
.body(bodyForType(type, dto.getBody()))
.body(sanitize(dto.getBody()))
.status(GeschichteStatus.DRAFT)
.type(type)
.author(currentUser())
.persons(resolvePersons(dto.getPersonIds()))
.documents(resolveDocuments(dto.getDocumentIds()))
.build();
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
g.setStatus(GeschichteStatus.PUBLISHED);
g.setPublishedAt(LocalDateTime.now());
}
Geschichte saved = geschichteRepository.save(g);
// A freshly created Geschichte has no items by construction — items are only
// addable via the separate /items endpoints. Revisit if a create DTO ever
// accepts initial items.
return toView(saved, List.of());
return geschichteRepository.save(g);
}
@Transactional
public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
if (dto.getType() != null && dto.getType() != g.getType()) {
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE,
"The type of a Geschichte cannot be changed after creation");
}
if (dto.getTitle() != null) {
requireTitle(dto.getTitle());
g.setTitle(dto.getTitle().trim());
}
if (dto.getBody() != null) {
g.setBody(bodyForType(g.getType(), dto.getBody()));
g.setBody(sanitize(dto.getBody()));
}
if (dto.getPersonIds() != null) {
g.setPersons(resolvePersons(dto.getPersonIds()));
}
if (dto.getDocumentIds() != null) {
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
}
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
applyStatusTransition(g, dto.getStatus());
}
Geschichte saved = geschichteRepository.save(g);
return toView(saved, journeyItemService.getItems(id));
return geschichteRepository.save(g);
}
@Transactional
@@ -223,27 +164,6 @@ public class GeschichteService {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Title is required");
}
if (title.trim().length() > MAX_TITLE_LENGTH) {
throw DomainException.badRequest(ErrorCode.GESCHICHTE_TITLE_TOO_LONG,
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
}
}
/**
* STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer.
* JOURNEY intros are plain text: the reader renders them via Svelte text
* interpolation (never {@code {@html}}), so entity-encoding them here would
* corrupt content ("&" → "&amp;") and re-encode on every editor round-trip.
*/
private String bodyForType(GeschichteType type, String body) {
if (type != GeschichteType.JOURNEY) {
return sanitize(body);
}
if (body != null && body.length() > MAX_INTRO_LENGTH) {
throw DomainException.badRequest(ErrorCode.GESCHICHTE_INTRO_TOO_LONG,
"Intro exceeds maximum length of " + MAX_INTRO_LENGTH + " characters");
}
return body;
}
private String sanitize(String body) {
@@ -256,6 +176,15 @@ public class GeschichteService {
return new LinkedHashSet<>(personService.getAllById(ids));
}
private Set<Document> resolveDocuments(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return new HashSet<>();
Set<Document> out = new LinkedHashSet<>();
for (UUID id : ids) {
out.add(documentService.getDocumentById(id));
}
return out;
}
private AppUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {

View File

@@ -6,6 +6,9 @@ import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person;
import org.springframework.data.jpa.domain.Specification;
@@ -45,7 +48,12 @@ public final class GeschichteSpecifications {
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
}
// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands
public static Specification<Geschichte> hasDocument(UUID documentId) {
return (root, query, cb) -> {
if (documentId == null) return null;
return cb.exists(documentSubquery(root, query, cb, documentId));
};
}
/**
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
@@ -76,4 +84,14 @@ public final class GeschichteSpecifications {
return sub;
}
private static Subquery<UUID> documentSubquery(
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
Subquery<UUID> sub = query.subquery(UUID.class);
Root<Geschichte> subRoot = sub.from(Geschichte.class);
Join<Geschichte, Document> documents = subRoot.join("documents");
sub.select(subRoot.get("id"))
.where(cb.equal(subRoot.get("id"), root.get("id")),
cb.equal(documents.get("id"), documentId));
return sub;
}
}

View File

@@ -1,45 +0,0 @@
package org.raddatz.familienarchiv.geschichte;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* List-projection for the /api/geschichten grid. Never carries items — avoids
* LazyInitializationException (open-in-view: false) and prevents Cartesian joins.
* Mirrors the PersonSummaryDTO precedent.
*
* <p>Field set: exactly what the live grid card renders (title, author byline, body excerpt,
* publishedAt, status, type). Does NOT carry items or persons.
*/
public interface GeschichteSummary {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
UUID getId();
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String getTitle();
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
GeschichteStatus getStatus();
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
GeschichteType getType();
/** Nested closed projection — exposes only the fields the grid card needs. */
AuthorSummary getAuthor();
LocalDateTime getPublishedAt();
/** Always set (@UpdateTimestamp) — drives "bearbeitet vor X" on dashboard cards. */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime getUpdatedAt();
String getBody();
/** Author projection — names only; never email or group memberships (same rule as GeschichteView.AuthorView). */
interface AuthorSummary {
String getFirstName();
String getLastName();
}
}

View File

@@ -1,6 +0,0 @@
package org.raddatz.familienarchiv.geschichte;
public enum GeschichteType {
STORY,
JOURNEY
}

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.geschichte;
import lombok.Data;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import java.util.List;
import java.util.UUID;
@@ -15,6 +16,6 @@ public class GeschichteUpdateDTO {
private String title;
private String body;
private GeschichteStatus status;
private GeschichteType type;
private List<UUID> personIds;
private List<UUID> documentIds;
}

View File

@@ -1,41 +0,0 @@
package org.raddatz.familienarchiv.geschichte;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Detail-view response for GET /api/geschichten/{id}. Assembled by
* GeschichteService — never the raw entity (author AppUser graph must not leak).
* items is always present (both STORY and JOURNEY); empty list for stories with no items.
*/
public record GeschichteView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
String body,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteStatus status,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteType type,
AuthorView author,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<PersonView> persons,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<JourneyItemView> items,
LocalDateTime publishedAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt
) {
/** Summarised author — exposes only id and displayName, never email or group memberships. */
public record AuthorView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName
) {}
/** Summarised person — exposes only id, firstName, and lastName. No admin-only fields. */
public record PersonView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
String firstName,
String lastName
) {}
}

View File

@@ -1,22 +0,0 @@
package org.raddatz.familienarchiv.geschichte;
/**
* Utility for joining a person's first and last name into a display string.
* Centralises the logic that was previously duplicated across GeschichteService
* and JourneyItemService.
*/
public class PersonNameFormatter {
private PersonNameFormatter() {
// utility class — no instances
}
public static String join(String firstName, String lastName) {
String first = firstName != null ? firstName.trim() : "";
String last = lastName != null ? lastName.trim() : "";
if (first.isEmpty() && last.isEmpty()) return "";
if (first.isEmpty()) return last;
if (last.isEmpty()) return first;
return first + " " + last;
}
}

View File

@@ -1,23 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.UUID;
/**
* Lean read-model view of a Document for embedding in JourneyItemView.
* Built by JourneyItemService.toSummary(Document) — never serialised from
* a JPA entity to avoid LazyInitializationException and tag-color overhead.
*/
public record DocumentSummary(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
LocalDate documentDate,
LocalDate documentDateEnd,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision datePrecision,
String senderName,
String receiverName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Integer receiverCount
) {}

View File

@@ -1,54 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import java.util.UUID;
@Entity
@Table(name = "journey_items")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class JourneyItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "geschichte_id", nullable = false)
@JsonIgnore
private Geschichte geschichte;
// Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order
// — the editor is responsible for keeping them distinct.
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private int position;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "document_id")
@JsonIgnore
private Document document;
/**
* Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output.
*
* <p>CWE-79 tripwire: stored verbatim; only Svelte {note} interpolation is auto-safe.</p>
*/
@Column(columnDefinition = "TEXT")
private String note;
// JPA uses field access — this getter is not persisted. Jackson serializes it as documentId.
// Exposing only the UUID prevents circular references and large nested payloads.
public UUID getDocumentId() {
return document != null ? document.getId() : null;
}
}

View File

@@ -1,12 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.Data;
import java.util.UUID;
/** Input for POST /api/geschichten/{id}/items. Both fields optional; at least one must be present. */
@Data
public class JourneyItemCreateDTO {
private UUID documentId;
private String note;
}

View File

@@ -1,30 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.document.DocumentDeletingEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
class JourneyItemDocumentDeleteListener {
private final JourneyItemRepository journeyItemRepository;
/**
* Plain @EventListener — runs synchronously in the publisher's thread and transaction.
* Load-bearing choice: AFTER_COMMIT would fire after the FK ON DELETE SET NULL has
* already 500'd; @Async would run outside the delete transaction (breaks AC-5 rollback).
* See ADR-038. DocumentService cannot call JourneyItemService directly because
* Spring Framework 7 prohibits the resulting constructor-injection cycle.
*/
@EventListener
void onDocumentDeleting(DocumentDeletingEvent event) {
int deleted = journeyItemRepository.deleteNoteLessByDocumentId(event.documentId());
if (deleted > 0) {
log.warn("Cascade-deleted {} note-less journey item(s) for document {}", deleted, event.documentId());
}
}
}

View File

@@ -1,69 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@Repository
public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID> {
/** Returns items ordered by position ASC for the read-model assembly path. */
List<JourneyItem> findByGeschichteIdOrderByPosition(UUID geschichteId);
/** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */
Optional<JourneyItem> findByIdAndGeschichteId(UUID id, UUID geschichteId);
/** Returns only the IDs — used for set-equality check in reorder. */
@Query("SELECT i.id FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
Set<UUID> findIdsByGeschichteId(@Param("geschichteId") UUID geschichteId);
/** MAX position for computing the next append position; returns empty when journey has no items. */
@Query("SELECT MAX(i.position) FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
Optional<Integer> findMaxPositionByGeschichteId(@Param("geschichteId") UUID geschichteId);
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
long countByGeschichteId(UUID geschichteId);
/**
* Dedup guard: true when the document is already linked to this journey.
* Explicit JPQL, not a derived query: the transient {@code getDocumentId()}
* getter on JourneyItem makes Spring Data resolve the derived path as a
* direct {@code documentId} attribute, which Hibernate cannot map.
*/
@Query("""
SELECT COUNT(i) > 0 FROM JourneyItem i
WHERE i.geschichte.id = :geschichteId AND i.document.id = :documentId
""")
boolean existsByGeschichteIdAndDocumentId(
@Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId);
/**
* Deletes note-less items (note IS NULL or note = '') linked to the given document.
* Used by JourneyItemDocumentDeleteListener before the document row is removed, so
* the FK ON DELETE SET NULL never fires on rows that would violate chk_journey_item_not_empty.
* Explicit JPQL — same trap as existsByGeschichteIdAndDocumentId: the transient
* getDocumentId() getter makes Spring Data unable to resolve a derived query path.
* clearAutomatically = true invalidates the L1 cache so AC-2's "note-carrying survives"
* assertion never reads a stale entity. flushAutomatically = true makes the
* flush-before-delete contract explicit rather than relying on Hibernate AUTO flush mode.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
/**
* Loads journey items with their linked Document in a single JOIN FETCH query,
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
* lazily for each item. Items without a document (note-only) are included via
* LEFT JOIN. Ordered by position ASC.
*/
@Query("SELECT ji FROM JourneyItem ji LEFT JOIN FETCH ji.document WHERE ji.geschichte.id = :geschichteId ORDER BY ji.position ASC")
List<JourneyItem> findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId);
}

View File

@@ -1,276 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@RequiredArgsConstructor
@Slf4j
public class JourneyItemService {
static final int MAX_ITEMS = 100;
static final int POSITION_STEP = 10;
// 2000 per the editor spec — frontend maxlength and the i18n error message agree (#793).
static final int MAX_NOTE_LENGTH = 2000;
private final JourneyItemRepository journeyItemRepository;
private final GeschichteQueryService geschichteQueryService;
private final DocumentService documentService;
private final AuditService auditService;
private final UserService userService;
@Transactional
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
Geschichte g = geschichteQueryService.findById(geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
"Geschichte not found: " + geschichteId));
long count = journeyItemRepository.countByGeschichteId(geschichteId);
if (count >= MAX_ITEMS) {
throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
"Journey has reached the maximum of 100 items");
}
String note = normalizeNote(dto.getNote());
if (dto.getDocumentId() == null && note == null) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"At least one of documentId or note must be provided");
}
if (note != null && note.length() > MAX_NOTE_LENGTH) {
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
}
Document doc = null;
if (dto.getDocumentId() != null) {
if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
"Document already in journey: " + dto.getDocumentId());
}
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
}
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
.map(max -> max + POSITION_STEP)
.orElse(POSITION_STEP);
JourneyItem item = JourneyItem.builder()
.geschichte(g)
.position(nextPosition)
.document(doc)
.note(note)
.build();
// saveAndFlush so the partial unique index on (geschichte_id, document_id)
// fires here, not at commit — two concurrent appends can both pass the
// exists() pre-check above, and the index is the atomic backstop (V74).
JourneyItem saved;
try {
saved = journeyItemRepository.saveAndFlush(item);
} catch (DataIntegrityViolationException e) {
// Only the dedup index earns the friendly 409 — any other integrity
// failure (e.g. an FK violation on a concurrently deleted document)
// must not be mislabeled as "already added".
if (!isDuplicateDocumentViolation(e)) {
throw e;
}
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
"Document already in journey: " + dto.getDocumentId());
}
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
Map.of("geschichteId", geschichteId, "itemId", saved.getId()));
return toView(saved);
}
@Transactional
public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
"Journey item not found: " + itemId));
// null = field absent from JSON → no-op
Optional<String> noteField = dto.getNote();
if (noteField == null) {
return toView(item);
}
String note = normalizeNote(noteField.orElse(null));
if (note != null && note.length() > MAX_NOTE_LENGTH) {
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
}
if (note == null && item.getDocumentId() == null) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Cannot clear note on an item that has no linked document");
}
item.setNote(note);
JourneyItem saved = journeyItemRepository.save(item);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null,
Map.of("geschichteId", geschichteId, "itemId", itemId));
return toView(saved);
}
@Transactional
public void delete(UUID geschichteId, UUID itemId) {
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
"Journey item not found: " + itemId));
journeyItemRepository.delete(item);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
Map.of("geschichteId", geschichteId, "itemId", itemId));
}
@Transactional
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
if (!geschichteQueryService.existsById(geschichteId)) {
throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
"Geschichte not found: " + geschichteId);
}
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Duplicate item IDs in reorder request");
}
if (!existingIds.equals(new HashSet<>(requestedIds))) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Requested item IDs do not match the journey's existing items");
}
if (requestedIds.isEmpty()) {
return List.of();
}
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
Map<UUID, JourneyItem> itemMap = new HashMap<>();
for (JourneyItem item : items) {
itemMap.put(item.getId(), item);
}
List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
for (int i = 0; i < requestedIds.size(); i++) {
JourneyItem item = itemMap.get(requestedIds.get(i));
item.setPosition((i + 1) * POSITION_STEP);
toSave.add(item);
}
List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));
return reordered.stream().map(this::toView).toList();
}
public List<JourneyItemView> getItems(UUID geschichteId) {
return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId)
.stream().map(this::toView).toList();
}
DocumentSummary toSummary(Document doc) {
String senderName = buildSenderName(doc);
Set<Person> receivers = doc.getReceivers();
String receiverName = buildCanonicalReceiverName(receivers);
return new DocumentSummary(
doc.getId(),
doc.getTitle(),
doc.getDocumentDate(),
doc.getMetaDateEnd(),
doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
senderName,
receiverName,
receivers != null ? receivers.size() : 0
);
}
JourneyItemView toView(JourneyItem item) {
DocumentSummary docSummary = null;
Document doc = item.getDocument();
if (doc != null) {
docSummary = toSummary(doc);
}
return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
}
private static String buildSenderName(Document doc) {
Person sender = doc.getSender();
if (sender != null) {
String name = PersonNameFormatter.join(sender.getFirstName(), sender.getLastName());
if (!name.isBlank()) return name;
}
String senderText = doc.getSenderText();
return (senderText != null && !senderText.isBlank()) ? senderText : null;
}
private static String buildCanonicalReceiverName(Set<Person> receivers) {
if (receivers == null || receivers.isEmpty()) return null;
return receivers.stream()
.min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
.map(p -> {
String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName());
return name.isBlank() ? null : name;
})
.orElse(null);
}
private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
Throwable cause = e.getCause();
if (cause instanceof java.sql.SQLException sql) {
return "23505".equals(sql.getSQLState());
}
return false;
}
private static String normalizeNote(String raw) {
if (raw == null || raw.isBlank()) return null;
return raw.trim();
}
private static String sortKey(String s) {
return s != null ? s : "";
}
private AppUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
return userService.findByEmail(auth.getName());
}
}

View File

@@ -1,19 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.Data;
import java.util.Optional;
/**
* Input for PATCH /api/geschichten/{id}/items/{itemId}.
* Three-way semantics via Optional<String>:
* null → field absent from JSON → leave note unchanged
* Optional.empty() → {"note": null} → clear the note
* Optional.of("x") → {"note": "x"} → set the note
*
* Jackson 3.x maps JSON null to Optional.empty(); absent fields keep the Java default (null).
*/
@Data
public class JourneyItemUpdateDTO {
private Optional<String> note = null;
}

View File

@@ -1,17 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
/**
* Read-model response for a JourneyItem. Never the JPA entity (which has a
* Geschichte back-reference that would leak / hit LazyInitializationException).
*/
public record JourneyItemView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
DocumentSummary document,
/** Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output. */
String note
) {}

View File

@@ -1,12 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.Data;
import java.util.List;
import java.util.UUID;
/** Input for PUT /api/geschichten/{id}/items/reorder. */
@Data
public class JourneyReorderDTO {
private List<UUID> itemIds;
}

View File

@@ -5,7 +5,6 @@ import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
import org.raddatz.familienarchiv.exception.DomainException;
@@ -75,7 +74,6 @@ public class DocumentImporter {
Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?");
private final DocumentService documentService;
private final DocumentTitleFactory documentTitleFactory;
private final PersonService personService;
private final TagService tagService;
private final S3Client s3Client;
@@ -183,7 +181,7 @@ public class DocumentImporter {
applyAttribution(doc, row);
applyDates(doc, row);
applyAuthoritativeAssociations(doc, row);
applyFileMetadata(doc, s3Key, contentType, status);
applyFileMetadata(doc, s3Key, contentType, status, index);
applyComputedFlags(doc);
return doc;
}
@@ -219,15 +217,14 @@ public class DocumentImporter {
attachTag(doc, row.get("tags"));
}
// S3 key, content type, status, and the index-derived title. The title formula lives in
// the document package's DocumentTitleFactory (single source of truth, #726); by this point
// applyDates has populated the date/location and originalFilename carries the index.
// S3 key, content type, status, and the index-derived title.
private void applyFileMetadata(Document doc, String s3Key, String contentType,
DocumentStatus status) {
DocumentStatus status, String index) {
doc.setStatus(status);
doc.setFilePath(s3Key);
doc.setContentType(contentType);
doc.setTitle(documentTitleFactory.build(doc));
doc.setTitle(buildTitle(index, doc.getDocumentDate(), doc.getMetaDatePrecision(),
doc.getMetaDateEnd(), doc.getMetaDateRaw(), doc.getLocation()));
}
// metadataComplete: a document counts as fully described if any of the three "who/when"
@@ -238,6 +235,20 @@ public class DocumentImporter {
|| !doc.getReceivers().isEmpty());
}
// The title carries the date at the HONEST precision (never a fabricated day) via the
// shared DocumentTitleFormatter, plus the location — kept under 20 lines by delegating.
private static String buildTitle(String index, LocalDate date, DatePrecision precision,
LocalDate end, String raw, String location) {
StringBuilder title = new StringBuilder(index);
if (date != null && precision != DatePrecision.UNKNOWN) {
title.append(" ").append(DocumentTitleFormatter.formatTitleDate(date, precision, end, raw));
}
if (location != null && !location.isBlank()) {
title.append(" ").append(location);
}
return title.toString();
}
// ─── attribution routing — register-first, always retain raw ─────────────────────
private Person resolveSender(String slug, String rawName) {

View File

@@ -1,4 +1,6 @@
package org.raddatz.familienarchiv.document;
package org.raddatz.familienarchiv.importing;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

View File

@@ -1,13 +0,0 @@
package org.raddatz.familienarchiv.person;
import java.util.List;
/**
* Result of {@link PersonService#resolveByName(String)}: candidate persons split by name-match
* strength. {@code direct} = every query token is a whole-token match across the person's name
* components (alias/maiden-name aware); {@code partial} = matched the substring fetch but is not
* direct. The vocabulary is deliberately name-match strength ({@code direct}/{@code partial}), not
* the search layer's resolved/ambiguous buckets — the caller maps these into its own outcome.
*/
public record NameMatches(List<Person> direct, List<Person> partial) {
}

View File

@@ -6,9 +6,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.user.DisplayNameFormatter;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@@ -51,25 +49,8 @@ public class Person {
@Column(columnDefinition = "TEXT")
private String notes;
// Most precise birth/death date known. Precision mirrors Document.metaDatePrecision:
// the date column is nullable, the precision column is NOT NULL with UNKNOWN meaning
// "no date" — the V76 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN).
// DatePrecision is imported cross-domain from document/ by design (ADR-039).
private LocalDate birthDate;
@Enumerated(EnumType.STRING)
@Column(name = "birth_date_precision", nullable = false, length = 16)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private DatePrecision birthDatePrecision = DatePrecision.UNKNOWN;
private LocalDate deathDate;
@Enumerated(EnumType.STRING)
@Column(name = "death_date_precision", nullable = false, length = 16)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private DatePrecision deathDatePrecision = DatePrecision.UNKNOWN;
private Integer birthYear;
private Integer deathYear;
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
// Nullable for persons outside the curated family graph. Drives the

View File

@@ -19,8 +19,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
"ORDER BY p.lastName ASC, p.firstName ASC")
List<Person> searchByName(@Param("query") String query);
@@ -30,46 +29,21 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
// Stammbaum-Knoten: alle Personen mit family_member = true.
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
// Exact-case alias lookup — the first resolution step in findOrCreateByAlias.
// Case-colliding aliases across persons (müller / Müller) are valid human labels, NOT
// duplicates: source_ref is the stable identity (ADR-025/033), alias is editable. Do NOT
// add a unique(lower(alias)) constraint — see ADR-033.
Optional<Person> findByAlias(String alias);
// Plural case-insensitive alias lookup — the fallback step. Returns ALL case-folding
// siblings so the service can pick a deterministic one (lowest id) instead of letting a
// derived Optional<…>IgnoreCase throw NonUniqueResultException. See ADR-033.
List<Person> findAllByAliasIgnoreCase(String alias);
// Lookup by full alias string, used during ODS mass import
Optional<Person> findByAliasIgnoreCase(String alias);
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
Optional<Person> findBySourceRef(String sourceRef);
// Exact-case first+last name match — the first step of filename-based sender resolution.
// Explicit `=` (HQL, not a derived query) so a null firstName binds as `first_name = NULL`
// — never a match — instead of the derived-query fold to `first_name IS NULL`, which would
// pull a last-name-only row in as a sender (a provenance defect). See ADR-033.
@Query("SELECT p FROM Person p WHERE p.firstName = :firstName AND p.lastName = :lastName")
Optional<Person> findByFirstNameAndLastName(@Param("firstName") String firstName,
@Param("lastName") String lastName);
// Plural case-insensitive first+last name match — lets findByName bail to empty on 2+ matches
// instead of letting a derived Optional<…>IgnoreCase throw NonUniqueResultException. Same
// null fail-closed guarantee as above: LOWER(:firstName) is NULL for a null arg, so a null
// first name resolves to no match (not first_name IS NULL widening). See ADR-033.
@Query("SELECT p FROM Person p WHERE LOWER(p.firstName) = LOWER(:firstName) "
+ "AND LOWER(p.lastName) = LOWER(:lastName)")
List<Person> findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(@Param("firstName") String firstName,
@Param("lastName") String lastName);
// Exact first+last name match, used for filename-based sender lookup
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
// --- PersonSummaryDTO with document count ---
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -82,10 +56,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -95,7 +66,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision, p.notes, p.family_member, p.provisional
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member, p.provisional
ORDER BY p.last_name ASC, p.first_name ASC
""",
nativeQuery = true)
@@ -106,10 +77,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -148,10 +116,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = """
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
p.person_type AS personType,
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
p.family_member AS familyMember, p.provisional AS provisional,
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
@@ -224,15 +189,18 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
// clearAutomatically + flushAutomatically keep the L1 cache from desyncing: these bulk
// updates run beneath Hibernate, and mergePersons follows them with a deleteById whose
// ON DELETE CASCADE (V71) also fires beneath the session.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Modifying
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
@Modifying(clearAutomatically = true, flushAutomatically = true)
// Used by deletePerson: detach a deleted person from documents they sent, so the hard
// delete cannot orphan a documents.sender_id FK (the column is nullable).
@Modifying
@Query(value = "UPDATE documents SET sender_id = NULL WHERE sender_id = :source", nativeQuery = true)
void reassignSenderToNull(@Param("source") UUID source);
@Modifying
@Query(value = """
INSERT INTO document_receivers (document_id, person_id)
SELECT document_id, :target FROM document_receivers
@@ -242,4 +210,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
)
""", nativeQuery = true)
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
@Modifying
@Query(value = "DELETE FROM document_receivers WHERE person_id = :source", nativeQuery = true)
void deleteReceiverReferences(@Param("source") UUID source);
}

View File

@@ -1,23 +1,14 @@
package org.raddatz.familienarchiv.person;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.lang.Nullable;
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
@@ -32,20 +23,11 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Service
@RequiredArgsConstructor
@Slf4j
public class PersonService {
// Co-located with the fetch loop that owns them (issue #763). MAX_TOKENS caps the number of
// unindexed leading-wildcard LIKE scans per name — a DoS control, not just perf. MAX_CANDIDATES
// bounds each result bucket and is applied AFTER classification so a direct match that sorts
// past position 10 among partials is never discarded.
private static final int MAX_TOKENS = 8;
private static final int MAX_CANDIDATES = 10;
private final PersonRepository personRepository;
private final PersonNameAliasRepository aliasRepository;
@@ -86,13 +68,15 @@ public class PersonService {
}
/**
* Hard-deletes a person used by triage. Referential integrity is enforced by the database
* (V71's {@code ON DELETE} constraints: sender_id {@code SET NULL}, receiver and @-mention
* rows {@code CASCADE}), so the service stays thin — it only verifies existence then deletes.
* Hard-deletes a person used by triage. Detaches the person from any documents they
* sent (nulls sender_id) and from any received-document references first, so the delete
* cannot orphan an FK and fail with a 500.
*/
@Transactional
public void deletePerson(UUID id) {
getById(id);
personRepository.reassignSenderToNull(id);
personRepository.deleteReceiverReferences(id);
personRepository.deleteById(id);
}
@@ -116,96 +100,6 @@ public class PersonService {
return personRepository.findAllById(ids);
}
public List<Person> findByDisplayNameContaining(String fragment) {
return personRepository.searchByName(fragment);
}
// Name-match tokenizer (issue #763): lowercase, split on whitespace/hyphen/apostrophe,
// drop empties. Applied symmetrically to the query and to every candidate name component so
// that "Anna-Maria" and "Anna Maria" tokenize alike. Order-preserving for deterministic tests.
static Set<String> tokenize(String raw) {
if (raw == null || raw.isBlank()) {
return Set.of();
}
LinkedHashSet<String> tokens = new LinkedHashSet<>();
for (String part : raw.toLowerCase(Locale.ROOT).split("[\\s\\-']+")) {
if (!part.isEmpty()) {
tokens.add(part);
}
}
return tokens;
}
/**
* Resolves an extracted person name into {@link NameMatches} by name-match strength.
* Orchestrates tokenize → cap → fetch pool → classify → cap-after-classify. Read-only
* transaction keeps the Hibernate session open so each candidate's lazy {@code nameAliases}
* are reachable during classification (see ADR-022).
*/
@Transactional(readOnly = true)
public NameMatches resolveByName(String name) {
Set<String> queryTokens = capTokens(tokenize(name));
if (queryTokens.isEmpty()) {
log.debug("resolveByName outcome=no-match tokens=0");
return new NameMatches(List.of(), List.of());
}
return classify(fetchPool(queryTokens), queryTokens);
}
private Set<String> capTokens(Set<String> tokens) {
return tokens.stream().limit(MAX_TOKENS).collect(Collectors.toCollection(LinkedHashSet::new));
}
private List<Person> fetchPool(Set<String> queryTokens) {
LinkedHashMap<UUID, Person> pool = new LinkedHashMap<>();
for (String token : queryTokens) {
for (Person candidate : findByDisplayNameContaining(token)) {
pool.putIfAbsent(candidate.getId(), candidate);
}
}
return new ArrayList<>(pool.values());
}
private NameMatches classify(List<Person> pool, Set<String> queryTokens) {
List<Person> direct = new ArrayList<>();
List<Person> partial = new ArrayList<>();
for (Person candidate : pool) {
if (personTokens(candidate).containsAll(queryTokens)) {
direct.add(candidate);
} else {
partial.add(candidate);
}
}
List<Person> cappedDirect = cap(direct);
List<Person> cappedPartial = cap(partial);
log.debug("resolveByName outcome={} tokens={}", outcome(cappedDirect, cappedPartial), queryTokens.size());
return new NameMatches(cappedDirect, cappedPartial);
}
private static Set<String> personTokens(Person person) {
Set<String> tokens = new LinkedHashSet<>();
tokens.addAll(tokenize(person.getFirstName()));
tokens.addAll(tokenize(person.getLastName()));
tokens.addAll(tokenize(person.getAlias()));
tokens.addAll(tokenize(person.getTitle()));
for (PersonNameAlias alias : person.getNameAliases()) {
tokens.addAll(tokenize(alias.getFirstName()));
tokens.addAll(tokenize(alias.getLastName()));
}
return tokens;
}
private static List<Person> cap(List<Person> people) {
return people.size() > MAX_CANDIDATES ? people.subList(0, MAX_CANDIDATES) : people;
}
private static String outcome(List<Person> direct, List<Person> partial) {
if (direct.size() == 1) return "direct=1";
if (direct.size() >= 2) return "direct>=2";
if (!partial.isEmpty()) return "partial-only";
return "no-match";
}
public List<Person> findAllFamilyMembers() {
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
}
@@ -218,19 +112,7 @@ public class PersonService {
}
public Optional<Person> findByName(String firstName, String lastName) {
// Same scope as findOrCreateByAlias (#731): a case-collision resolves without throwing;
// two byte-identical same-case persons are an out-of-scope data anomaly the exact
// Optional below would surface as the opaque INTERNAL_ERROR, not a wrong sender.
Optional<Person> exact = personRepository.findByFirstNameAndLastName(firstName, lastName);
if (exact.isPresent()) return exact;
List<Person> caseInsensitive =
personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
// Deliberate divergence from findOrCreateByAlias: an ambiguous filename leaves the sender
// UNSET rather than picking the lowest id. The archive's value is correct provenance — a
// confidently-wrong pre-filled "Hans Müller" is worse than an empty field, because a
// reviewer won't re-check a pre-filled value. Do NOT "consistency-clean" this into the
// lowest-id fallback. See ADR-033.
return caseInsensitive.size() == 1 ? Optional.of(caseInsensitive.get(0)) : Optional.empty();
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
}
/** Lookup by the normalizer person_id — used by the canonical importer for register-first matching. */
@@ -245,45 +127,32 @@ public class PersonService {
PersonType type = PersonTypeClassifier.classify(alias);
if (type == PersonType.SKIP) return null;
// Aliases differing only by case (müller / Müller) are valid distinct persons, not
// duplicates, so a CASE-COLLISION must not throw: exact-case first, then the lowest-id
// case-insensitive sibling, then create. Mirrors the tag path — see ADR-033.
// Scope (#731): "ambiguous" means case-insensitive. Two BYTE-IDENTICAL same-case aliases
// are a true data anomaly out of scope here; the exact Optional below would surface that
// as the opaque INTERNAL_ERROR (never a wrong row), not silently pick one.
Optional<Person> exact = personRepository.findByAlias(alias);
if (exact.isPresent()) return exact.get(); // exact-case wins
List<Person> caseInsensitive = personRepository.findAllByAliasIgnoreCase(alias);
if (!caseInsensitive.isEmpty()) {
return caseInsensitive.stream().min(Comparator.comparing(Person::getId)).orElseThrow(); // deterministic tie-break — list is non-empty, never throws
}
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
return personRepository.save(Person.builder()
.alias(alias)
.lastName(alias)
.personType(type)
.build());
}
// Create-when-absent: institution/group keep the full label in lastName; a person name
// is split and a maiden name (geb. …) becomes a MAIDEN_NAME alias.
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
return personRepository.save(Person.builder()
PersonNameParser.SplitName split = PersonNameParser.split(alias);
Person person = personRepository.save(Person.builder()
.alias(alias)
.lastName(alias)
.personType(type)
.firstName(split.firstName())
.lastName(split.lastName())
.build());
}
PersonNameParser.SplitName split = PersonNameParser.split(alias);
Person person = personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.build());
if (split.maidenName() != null) {
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
aliasRepository.save(PersonNameAlias.builder()
.person(person)
.lastName(split.maidenName())
.type(PersonNameAliasType.MAIDEN_NAME)
.sortOrder(nextSortOrder)
.build());
}
return person;
if (split.maidenName() != null) {
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
aliasRepository.save(PersonNameAlias.builder()
.person(person)
.lastName(split.maidenName())
.type(PersonNameAliasType.MAIDEN_NAME)
.sortOrder(nextSortOrder)
.build());
}
return person;
});
}
/**
@@ -301,20 +170,13 @@ public class PersonService {
}
private Person fromCanonical(PersonUpsertCommand cmd) {
DatePrecisionPair none = new DatePrecisionPair(null, DatePrecision.UNKNOWN);
LifeDates dates = degradeIfConflicting(
yearPair(cmd.birthYear()), yearPair(cmd.deathYear()), none, none, cmd.sourceRef());
DatePrecisionPair birth = dates.birth();
DatePrecisionPair death = dates.death();
Person person = personRepository.save(Person.builder()
.sourceRef(cmd.sourceRef())
.firstName(blankToNull(cmd.firstName()))
.lastName(cmd.lastName())
.notes(blankToNull(cmd.notes()))
.birthDate(birth.date())
.birthDatePrecision(birth.precision())
.deathDate(death.date())
.deathDatePrecision(death.precision())
.birthYear(cmd.birthYear())
.deathYear(cmd.deathYear())
.generation(cmd.generation())
.familyMember(cmd.familyMember())
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
@@ -337,16 +199,8 @@ public class PersonService {
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
LifeDates dates = degradeIfConflicting(
preferHumanDate(existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear()),
preferHumanDate(existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear()),
new DatePrecisionPair(existing.getBirthDate(), existing.getBirthDatePrecision()),
new DatePrecisionPair(existing.getDeathDate(), existing.getDeathDatePrecision()),
cmd.sourceRef());
existing.setBirthDate(dates.birth().date());
existing.setBirthDatePrecision(dates.birth().precision());
existing.setDeathDate(dates.death().date());
existing.setDeathDatePrecision(dates.death().precision());
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
existing.setPersonType(cmd.personType());
@@ -373,48 +227,6 @@ public class PersonService {
return existing != null ? existing : canonical;
}
// Date + precision travel as one value so they can never go out of sync (ADR-039).
record DatePrecisionPair(LocalDate date, DatePrecision precision) {}
record LifeDates(DatePrecisionPair birth, DatePrecisionPair death) {}
// The canonical path skips validateLifeDates (the form-only guard), so a conflicting
// resolved pair would hit chk_person_birth_before_death at flush time and abort the
// whole import batch with a raw 500. Degrade instead (REQ-IMP-001: never abort the
// batch): keep the person's stored life dates — empty for a new person — and drop the
// conflicting canonical refresh. A hand-entered side is preserved by construction,
// since preferHumanDate returned it verbatim and it equals the stored value; two
// stored values can never conflict with each other (they already satisfied the CHECK).
static LifeDates degradeIfConflicting(DatePrecisionPair birth, DatePrecisionPair death,
DatePrecisionPair existingBirth, DatePrecisionPair existingDeath,
String sourceRef) {
if (birth.date() == null || death.date() == null || !birth.date().isAfter(death.date())) {
return new LifeDates(birth, death);
}
log.warn("Conflicting canonical life dates for {}: birth {} is after death {} — keeping stored values",
sourceRef, birth.date(), death.date());
return new LifeDates(existingBirth, existingDeath);
}
// preferHuman for life dates (ADR-025 extension): a hand-entered date more precise than
// the spreadsheet's year (DAY/MONTH/SEASON/RANGE/APPROX) is preserved on re-import; a
// YEAR-precision or absent date is refreshed from the canonical year.
static DatePrecisionPair preferHumanDate(LocalDate existingDate, DatePrecision existingPrecision,
Integer canonicalYear) {
boolean handEntered = existingDate != null && existingPrecision != null
&& existingPrecision != DatePrecision.YEAR && existingPrecision != DatePrecision.UNKNOWN;
if (handEntered) {
return new DatePrecisionPair(existingDate, existingPrecision);
}
return yearPair(canonicalYear);
}
private static DatePrecisionPair yearPair(Integer year) {
return year != null
? new DatePrecisionPair(LocalDate.of(year, 1, 1), DatePrecision.YEAR)
: new DatePrecisionPair(null, DatePrecision.UNKNOWN);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
@@ -434,8 +246,7 @@ public class PersonService {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
}
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
dto.getDeathDate(), dto.getDeathDatePrecision());
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = Person.builder()
.personType(dto.getPersonType())
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
@@ -443,49 +254,31 @@ public class PersonService {
.lastName(dto.getLastName())
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
.birthDate(dto.getBirthDate())
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
.deathDate(dto.getDeathDate())
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
.birthYear(dto.getBirthYear())
.deathYear(dto.getDeathYear())
.generation(dto.getGeneration())
.build();
return personRepository.save(person);
}
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
LocalDate deathDate, DatePrecision deathPrecision) {
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
"Birth date " + birthDate + " is after death date " + deathDate);
private void validateYears(Integer birthYear, Integer deathYear) {
if (birthYear != null && birthYear <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
}
}
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date is set but its precision is missing or UNKNOWN");
if (deathYear != null && deathYear <= 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
}
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
side + " date precision " + precision + " is set without a date");
if (birthYear != null && deathYear != null && birthYear > deathYear) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
}
}
private static DatePrecision normalizePrecision(DatePrecision precision) {
return precision == null ? DatePrecision.UNKNOWN : precision;
}
@Transactional
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
if (dto.getPersonType() == PersonType.SKIP) {
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
}
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
dto.getDeathDate(), dto.getDeathDatePrecision());
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
person.setPersonType(dto.getPersonType());
@@ -494,22 +287,14 @@ public class PersonService {
person.setLastName(dto.getLastName());
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthDate(dto.getBirthDate());
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
person.setDeathDate(dto.getDeathDate());
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
person.setBirthYear(dto.getBirthYear());
person.setDeathYear(dto.getDeathYear());
// Form path: a human can clear generation back to null. Unlike the importer
// which routes through preferHuman, we write the DTO value verbatim.
person.setGeneration(dto.getGeneration());
return personRepository.save(person);
}
/**
* Merges the source person into the target, then deletes the source. Sender references move
* to the target; receiver references the target lacks are inserted. The source's leftover
* receiver join rows are not deleted explicitly — they cascade-drop via V71's
* {@code ON DELETE CASCADE} on {@code document_receivers.person_id} when the source is deleted.
*/
@Transactional
public void mergePersons(UUID sourceId, UUID targetId) {
if (sourceId.equals(targetId)) {
@@ -526,7 +311,9 @@ public class PersonService {
// Add target as receiver where source is receiver but target is not yet
personRepository.insertMissingReceiverReference(sourceId, targetId);
// Source's remaining receiver rows cascade-drop via V71's ON DELETE CASCADE.
// Remove all remaining source receiver references (duplicates already handled)
personRepository.deleteReceiverReferences(sourceId);
personRepository.deleteById(sourceId);
}

View File

@@ -1,8 +1,5 @@
package org.raddatz.familienarchiv.person;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.UUID;
/**
@@ -19,13 +16,6 @@ public interface PersonSummaryDTO {
String getAlias();
Integer getBirthYear();
Integer getDeathYear();
// Full date + precision alongside the derived years: list consumers that render
// precise life dates (mention dropdown) read these; year-only consumers keep
// the cheaper getBirthYear/getDeathYear.
LocalDate getBirthDate();
DatePrecision getBirthDatePrecision();
LocalDate getDeathDate();
DatePrecision getDeathDatePrecision();
String getNotes();
boolean isFamilyMember();
boolean isProvisional();

View File

@@ -5,11 +5,8 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.person.PersonType;
import java.time.LocalDate;
@Data
public class PersonUpdateDTO {
@NotNull
@@ -24,10 +21,8 @@ public class PersonUpdateDTO {
private String alias;
@Size(max = 5000)
private String notes;
private LocalDate birthDate;
private DatePrecision birthDatePrecision;
private LocalDate deathDate;
private DatePrecision deathDatePrecision;
private Integer birthYear;
private Integer deathYear;
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
// PersonGeneration so DB, DTO, and importer all read from one place.
@Min(PersonGeneration.MIN_GENERATION)

View File

@@ -20,9 +20,8 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
| `findAll(String q)` | document, dashboard | List all persons |
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
| `resolveByName(String name)` | search | NL-search name resolution returning `NameMatches` (direct vs partial). Token/word-boundary, alias-aware matching so a single direct match auto-selects even when looser substring hits coexist ("Clara Cram" vs "Clara Cramer"). See #763. |
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
| `findByName(String firstName, String lastName)` | document | Typeahead search |
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally |
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
| `findCorrespondents()` | document | Correspondent list for conversation filter |
| `count()` | dashboard | Total person count for stats |

View File

@@ -96,9 +96,7 @@ public class RelationshipInferenceService {
if (p == null) continue;
List<RelationToken> path = shortestPaths.get(id);
PersonNodeDTO node = new PersonNodeDTO(
p.getId(), p.getDisplayName(),
RelationshipService.yearOf(p.getBirthDate()),
RelationshipService.yearOf(p.getDeathDate()),
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
p.getGeneration(), p.isFamilyMember());
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
}

View File

@@ -15,7 +15,6 @@ import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -67,8 +66,7 @@ public class RelationshipService {
for (Person p : familyMembers) {
familyIds.add(p.getId());
nodes.add(new PersonNodeDTO(
p.getId(), p.getDisplayName(),
yearOf(p.getBirthDate()), yearOf(p.getDeathDate()),
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
p.getGeneration(), true));
}
@@ -157,13 +155,6 @@ public class RelationshipService {
return (s == null || s.isBlank()) ? null : s.trim();
}
// Stammbaum DTOs stay year-shaped: derive the year from the LocalDate, null-safe
// for persons with no date entered (ADR-039, REQ-PERSON-DATE-01). Package-private
// so RelationshipInferenceService shares the same derivation.
static Integer yearOf(LocalDate date) {
return date != null ? date.getYear() : null;
}
private static void validateYears(Integer fromYear, Integer toYear) {
if (fromYear != null && toYear != null && toYear < fromYear) {
throw DomainException.badRequest(
@@ -179,11 +170,11 @@ public class RelationshipService {
p.getId(),
rp.getId(),
p.getDisplayName(),
yearOf(p.getBirthDate()),
yearOf(p.getDeathDate()),
p.getBirthYear(),
p.getDeathYear(),
rp.getDisplayName(),
yearOf(rp.getBirthDate()),
yearOf(rp.getDeathDate()),
rp.getBirthYear(),
rp.getDeathYear(),
r.getRelationType(),
r.getFromYear(),
r.getToYear(),

View File

@@ -7,13 +7,6 @@ Hierarchical document categories. Tags form a tree via a self-referencing `paren
Entity: `Tag` (self-referencing `parent_id` tree).
Features: tag CRUD, hierarchical deletion (cascade to descendants), tag typeahead, admin tag management (rename, reparent, merge).
## Tag tree counts (`getTagTree`)
`GET /api/tags/tree` returns each node with **two** document counts, from two aggregate queries (no N+1):
- `documentCount` — documents tagged with that **exact** tag (direct). Read by the admin surfaces (sidebar tree, merge preview, delete-impact guard), which describe direct-document operations.
- `subtreeDocumentCount`**distinct** documents tagged with that tag **or any descendant** (subtree rollup, recursive-CTE closure, depth guard ≤50). Read by the reader surfaces (`/themen` page, dashboard `ThemenWidget`) so the box number matches what `/documents?tag=X` actually finds.
## What this domain does NOT own
- Documents — the `document_tags` join table is on the document side. `Tag` does not hold document references.

View File

@@ -20,14 +20,7 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
}
// Tag-name resolution (see TagService.findOrCreate). Names that collide case-insensitively across
// the canonical tree are VALID — a parent and its same-named lowercase child (e.g. "Geburt" /
// "Geburt/geburt") are distinct nodes with their own source_ref and document attachments. So
// resolution must be exact-case first, then a non-throwing list for the case-insensitive fallback.
// Do NOT add a unique(lower(name)) constraint — it would reject these legitimate rows. See #730.
Optional<Tag> findByName(String name);
List<Tag> findAllByNameIgnoreCase(String name);
Optional<Tag> findByNameIgnoreCase(String name);
// Lookup by the canonical tag_path, used for idempotent canonical re-import (Phase 3).
Optional<Tag> findBySourceRef(String sourceRef);
@@ -133,31 +126,4 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
*/
@Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true)
List<TagCount> findDocumentCountsPerTag();
/**
* Returns (tagId, count) pairs where count is the number of <b>distinct</b> documents tagged
* with that tag <b>or any of its descendants</b> (full subtree rollup).
* <p>
* Builds a tag closure of (ancestor_id, descendant_id) pairs via a recursive CTE — each tag is
* its own ancestor at depth 0, then descends into children (depth guard of 50 levels prevents a
* cycle or pathological depth from running away) — joins it to {@code document_tags} on the
* descendant, and counts distinct documents per ancestor. A document tagged with several tags in
* the same subtree is therefore counted once. Tags whose entire subtree holds no documents do
* not appear in the result (they default to 0 in the tree). One aggregate query for all tags.
*/
@Query(value = """
WITH RECURSIVE closure AS (
SELECT id AS ancestor_id, id AS descendant_id, 0 AS depth FROM tag
UNION ALL
SELECT c.ancestor_id, t.id AS descendant_id, c.depth + 1
FROM tag t
JOIN closure c ON t.parent_id = c.descendant_id
WHERE c.depth < 50
)
SELECT c.ancestor_id AS tagId, COUNT(DISTINCT dt.document_id) AS count
FROM closure c
JOIN document_tags dt ON dt.tag_id = c.descendant_id
GROUP BY c.ancestor_id
""", nativeQuery = true)
List<TagCount> findSubtreeDocumentCountsPerTag();
}

View File

@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.tag;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -46,10 +45,6 @@ public class TagService {
return enrichWithRelatives(matched);
}
public List<Tag> findByNameContaining(String fragment) {
return tagRepository.findByNameContainingIgnoreCase(fragment);
}
public Tag getById(UUID id) {
return tagRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
@@ -60,21 +55,10 @@ public class TagService {
return tagRepository.findBySourceRef(sourceRef);
}
/**
* Resolves a tag name to a single tag, creating one when absent. Never throws on case-insensitive
* collisions: names that differ only by case are valid distinct nodes in the canonical tree (a
* parent and its same-named lowercase child), so resolution prefers an exact-case match, then
* falls back to the lowest-id case-insensitive match, then creates. See #730.
*/
public Tag findOrCreate(String name) {
String cleanName = name.trim();
Optional<Tag> exact = tagRepository.findByName(cleanName);
if (exact.isPresent()) return exact.get(); // exact-case wins (edit round-trip replays the stored name)
List<Tag> caseInsensitive = tagRepository.findAllByNameIgnoreCase(cleanName);
if (!caseInsensitive.isEmpty()) {
return caseInsensitive.stream().min(Comparator.comparing(Tag::getId)).orElseThrow(); // deterministic tie-break by id — list is non-empty, never throws
}
return tagRepository.save(Tag.builder().name(cleanName).build()); // create-when-absent (orphan tag: null sourceRef/parentId)
return tagRepository.findByNameIgnoreCase(cleanName)
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
}
/**
@@ -188,27 +172,19 @@ public class TagService {
}
/**
* Returns all tags assembled into a tree, each node carrying two counts:
* {@code documentCount} — documents tagged with that exact tag (direct) — and
* {@code subtreeDocumentCount} — distinct documents tagged with that tag or any descendant
* (subtree rollup). Each count comes from one aggregate query (no N+1).
* NOTE: counts are global per tag, not scoped to any search filter.
* Consumed by the reader surfaces (/themen page, dashboard ThemenWidget — which read the
* subtree rollup) as well as the admin sidebar and tag operation previews (which read the
* direct count).
* Returns all tags assembled into a tree with document counts per node.
* Uses a single aggregate query to avoid N+1 behaviour.
* NOTE: document counts are global per tag, not scoped to any search filter.
* The tree endpoint is only used for the admin sidebar, so this is intentional.
*/
public List<TagTreeNodeDTO> getTagTree() {
List<Tag> all = tagRepository.findAll();
Map<UUID, Long> counts = toCountMap(tagRepository.findDocumentCountsPerTag());
Map<UUID, Long> subtreeCounts = toCountMap(tagRepository.findSubtreeDocumentCountsPerTag());
return buildTree(all, counts, subtreeCounts);
}
private static Map<UUID, Long> toCountMap(List<TagRepository.TagCount> counts) {
return counts.stream().collect(Collectors.toMap(
TagRepository.TagCount::getTagId,
TagRepository.TagCount::getCount
));
Map<UUID, Long> counts = tagRepository.findDocumentCountsPerTag().stream()
.collect(Collectors.toMap(
TagRepository.TagCount::getTagId,
TagRepository.TagCount::getCount
));
return buildTree(all, counts);
}
// ─── private helpers ─────────────────────────────────────────────────────
@@ -283,14 +259,12 @@ public class TagService {
}
}
private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts,
Map<UUID, Long> subtreeCounts) {
private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts) {
Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
for (Tag tag : tags) {
int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
int subtreeDocumentCount = subtreeCounts.getOrDefault(tag.getId(), 0L).intValue();
nodeById.put(tag.getId(), new TagTreeNodeDTO(
tag.getId(), tag.getName(), tag.getColor(), documentCount, subtreeDocumentCount,
tag.getId(), tag.getName(), tag.getColor(), documentCount,
new ArrayList<>(), tag.getParentId()
));
}

View File

@@ -10,8 +10,5 @@ public record TagTreeNodeDTO(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
String color,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED,
description = "Distinct documents tagged with this tag or any descendant tag (subtree rollup)")
int subtreeDocumentCount,
List<TagTreeNodeDTO> children,
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}

View File

@@ -51,12 +51,6 @@ public class AdminController {
return ResponseEntity.ok(new BackfillResult(count));
}
@PostMapping("/backfill-titles")
public ResponseEntity<BackfillResult> backfillTitles() {
int count = documentService.backfillTitles();
return ResponseEntity.ok(new BackfillResult(count));
}
@PostMapping("/generate-thumbnails")
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
thumbnailBackfillService.runBackfillAsync();

View File

@@ -11,4 +11,3 @@ springdoc:
swagger-ui:
enabled: true
path: /swagger-ui.html

View File

@@ -1,53 +0,0 @@
-- Move person-delete referential integrity from application code into the database (#684).
--
-- Before this migration, PersonService.deletePerson nulled documents.sender_id and removed
-- document_receivers rows in Java before deleting the person, because the two V1 FKs into
-- persons had no ON DELETE behaviour. Any other delete path (a future endpoint, a manual
-- psql, a batch job) could still orphan rows or 500. This migration makes the database the
-- single source of truth so a person delete is safe from every path.
--
-- Cascade boundary: the cascade stays STRICTLY at the join/reference layer and NEVER reaches
-- documents rows — a cascade into documents would destroy historical letters. sender_id is
-- SET NULL (documents.senderText preserves the raw textual attribution); the receiver join
-- row and the @-mention sidecar row are dropped.
--
-- No NOT VALID + VALIDATE two-step: these tables are small (thousands of rows → sub-second
-- ACCESS EXCLUSIVE lock). Do NOT copy this drop-and-recreate pattern onto a large table.
--
-- Not audit-logged: a DB ON DELETE cascade runs below AuditService — a known, accepted trade.
-- The person-delete action itself is still logged at the service layer.
-- documents.sender_id → ON DELETE SET NULL (deleted sender clears the link; the document survives).
ALTER TABLE public.documents
DROP CONSTRAINT fkl5xhww7es3b4um01vmly4y18m,
ADD CONSTRAINT fkl5xhww7es3b4um01vmly4y18m
FOREIGN KEY (sender_id) REFERENCES public.persons(id) ON DELETE SET NULL;
-- document_receivers.person_id → ON DELETE CASCADE (drop the join row), the symmetric
-- completion of V14, which added the same to the document_id side of this table.
ALTER TABLE public.document_receivers
DROP CONSTRAINT fkcg7r68qvosqricx1betgrlt7s,
ADD CONSTRAINT fkcg7r68qvosqricx1betgrlt7s
FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE;
-- Soft reference fix: transcription_block_mentioned_persons.person_id was a UUID with no FK
-- (V56), so deleting a person left dangling mention rows. Give it a real FK with CASCADE.
-- This reverses V56's deliberate "no FK on person_id" choice — that comment is now historical
-- but is intentionally left untouched, because editing an already-applied migration changes its
-- Flyway checksum and would fail validateOnMigrate in prod. ADR-032 is the authoritative record.
-- Clean up pre-existing orphans first — production likely holds dangling rows because the old
-- deletePerson never cleaned mention rows, and the ADD CONSTRAINT validation scan fails on them.
-- A DO block with RAISE NOTICE surfaces the purge count: Flyway runs each statement via JDBC
-- and discards a trailing SELECT's result set, so a "SELECT count(*)" would log nothing.
DO $$
DECLARE removed int;
BEGIN
DELETE FROM transcription_block_mentioned_persons m
WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id);
GET DIAGNOSTICS removed = ROW_COUNT;
RAISE NOTICE 'V71 orphaned_mention_rows_removed=%', removed;
END $$;
ALTER TABLE public.transcription_block_mentioned_persons
ADD CONSTRAINT fk_tbmp_person
FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE;

View File

@@ -1,73 +0,0 @@
-- Production pre-requisite — run BEFORE applying this migration:
-- docker exec familienarchiv-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-- -c "SELECT COUNT(DISTINCT (geschichte_id, document_id)) FROM geschichten_documents;"'
-- docker exec familienarchiv-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \
-- --table=geschichten_documents \
-- -f /tmp/pre_v72_backup_'"$(date +%Y%m%d)"'.sql'
-- Take the dump even if geschichten_documents is empty — it captures the table DEFINITION
-- for emergency reconstruction. The DROP TABLE is the only irreversible step; the
-- INSERT...SELECT is a no-op when there is no data. No DDL rollback path exists after commit.
--
-- REVERSE PROCEDURE (if V72 must be rolled back): restore the pre-V72 dump, then re-derive
-- the junction from the new table:
-- INSERT INTO geschichten_documents (geschichte_id, document_id)
-- SELECT geschichte_id, document_id FROM journey_items WHERE document_id IS NOT NULL;
-- Note: the reconstructed junction FK is ON DELETE CASCADE per the original V58
-- (NOT the new SET NULL of journey_items). Domain FKs target app_users (post-V60) —
-- do NOT hand-type V58's verbatim "REFERENCES users" DDL nor copy journey_items' SET NULL
-- into the reconstructed junction.
--
-- ASSUMPTION AS-001: The old geschichten_documents was an unordered Set — no curator order
-- existed. Ordering by meta_date is a plausible default a Lesereise lets curators
-- re-sequence. This is not a requirement; it is the best available approximation.
--
-- ASSUMPTION AS-002: Existing published Geschichten (STORYs) render the related-letters block;
-- this block visibly degrades to generic links (loss of per-document title AND date) for ALL
-- current readers during the stub window. Accepted because the reader follow-on is the
-- next-priority blocking dependency.
-- Step 1: Add type discriminator column to geschichten
ALTER TABLE geschichten
ADD COLUMN type VARCHAR(50) DEFAULT 'STORY' NOT NULL;
-- Step 2: Create journey_items table
CREATE TABLE journey_items (
id UUID NOT NULL DEFAULT gen_random_uuid(),
geschichte_id UUID NOT NULL,
position INT NOT NULL,
document_id UUID,
note TEXT,
CONSTRAINT pk_journey_items PRIMARY KEY (id),
CONSTRAINT fk_journey_items_geschichte
FOREIGN KEY (geschichte_id) REFERENCES geschichten(id) ON DELETE CASCADE,
CONSTRAINT fk_journey_items_document
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE SET NULL,
CONSTRAINT chk_journey_item_not_empty
CHECK (document_id IS NOT NULL OR note IS NOT NULL)
);
-- Step 3: Index for ordered retrieval by geschichte + position
CREATE INDEX idx_journey_items_geschichte_position
ON journey_items (geschichte_id, position ASC);
-- Step 4: Migrate geschichten_documents → journey_items
-- Positions are multiples of 1000 (headroom for drag-reorder).
-- Ordered by meta_date ASC NULLS LAST, then documents.id ASC as deterministic tiebreaker.
-- SELECT DISTINCT guards against duplicate junction rows producing duplicate journey items.
INSERT INTO journey_items (id, geschichte_id, position, document_id)
SELECT
gen_random_uuid(),
gd.geschichte_id,
(ROW_NUMBER() OVER (
PARTITION BY gd.geschichte_id
ORDER BY d.meta_date ASC NULLS LAST, d.id ASC
) * 1000)::INT AS position,
gd.document_id
FROM (
SELECT DISTINCT geschichte_id, document_id
FROM geschichten_documents
) gd
LEFT JOIN documents d ON d.id = gd.document_id;
-- Step 5: Drop the old junction table (irreversible — take the pg_dump first)
DROP TABLE geschichten_documents;

View File

@@ -1,19 +0,0 @@
-- Adds the two constraints that V72 deferred:
-- 1. UNIQUE(geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
-- Allows mid-transaction position swaps during reorder (checked at COMMIT, not per-row).
-- Requires transaction-level or session-level connection pooling (prod uses PgBouncer
-- in transaction mode — correct today; a future switch to statement-level would silently
-- break deferred checking at COMMIT).
-- 2. CHECK (position > 0) — defense against off-by-one in the append path.
--
-- MUST run in a single transaction; Flyway's default per-migration transaction satisfies this.
-- Do NOT add executeInTransaction=false or any callback that splits this migration.
ALTER TABLE journey_items
ADD CONSTRAINT uq_journey_items_geschichte_position
UNIQUE (geschichte_id, position)
DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE journey_items
ADD CONSTRAINT chk_journey_item_position
CHECK (position > 0);

View File

@@ -1,37 +0,0 @@
-- Two constraints the service-level checks need as atomic backstops:
--
-- 1. Partial unique index on (geschichte_id, document_id): the append dedup
-- guard is a check-then-insert (existsByGeschichteIdAndDocumentId), so two
-- concurrent appends of the same document can both pass the pre-check.
-- The index rejects the second INSERT; JourneyItemService.append translates
-- the DataIntegrityViolationException into the same 409
-- JOURNEY_DOCUMENT_ALREADY_ADDED as the friendly pre-check.
-- Partial (WHERE document_id IS NOT NULL) — note-only interludes must not collide.
--
-- 2. CHECK on note length: mirrors chk_text_length on transcription_blocks.
-- 2000 is the spec'd limit — JourneyItemService.MAX_NOTE_LENGTH, the frontend
-- maxlength, and the i18n error message all agree (#793).
--
-- Defensive cleanup first: a database that served writes on the base branch
-- (no dedup guard, MAX_NOTE_LENGTH = 5000) can hold rows that would make the
-- DDL below fail mid-migration and boot-loop the backend on a failed Flyway
-- row. Both statements are no-ops on a clean database.
-- Keep the earliest-positioned row of each (geschichte, document) pair.
DELETE FROM journey_items a
USING journey_items b
WHERE a.geschichte_id = b.geschichte_id
AND a.document_id = b.document_id
AND a.document_id IS NOT NULL
AND a.position > b.position;
-- Clamp over-long notes written under the old 5000-char service limit.
UPDATE journey_items SET note = left(note, 2000) WHERE length(note) > 2000;
CREATE UNIQUE INDEX uq_journey_items_geschichte_document
ON journey_items (geschichte_id, document_id)
WHERE document_id IS NOT NULL;
ALTER TABLE journey_items
ADD CONSTRAINT chk_journey_item_note_length
CHECK (note IS NULL OR length(note) <= 2000);

View File

@@ -1,16 +0,0 @@
-- JOURNEY intros travel the verbatim (unsanitized) write path and get the same
-- three-layer bound as journey notes: frontend maxlength, the
-- GeschichteService.MAX_INTRO_LENGTH check, and this CHECK as the atomic backstop.
-- STORY bodies are sanitized Tiptap HTML and stay unbounded on purpose.
--
-- The title needs no CHECK here — VARCHAR(255) (V58) already bounds it at the
-- DB layer; the service-level check exists to turn that 500 into a friendly 400.
-- Defensive clamp first: intros written before this migration may exceed the
-- cap. No-op on a clean database.
UPDATE geschichten SET body = left(body, 4000)
WHERE type = 'JOURNEY' AND length(body) > 4000;
ALTER TABLE geschichten
ADD CONSTRAINT chk_geschichte_journey_intro_length
CHECK (type <> 'JOURNEY' OR body IS NULL OR length(body) <= 4000);

View File

@@ -1,42 +0,0 @@
-- V76: persons.birth_year/death_year (integer) → birth_date/death_date (date)
-- plus NOT NULL precision columns mirroring documents.meta_date_precision.
-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-039).
-- One-way migration: rollback is a targeted pg_restore -t persons from the
-- pre-deploy backup (see docs/DEPLOYMENT.md).
-- Pre-check (data quality gate — not a race guard): abort on corrupt year data
-- before any DDL runs. Single-writer family archive, so no race window matters.
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year)
THEN RAISE EXCEPTION 'V76 aborted: % persons have birth_year > death_year — fix data before migrating',
(SELECT COUNT(*) FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year);
END IF;
IF EXISTS (SELECT 1 FROM persons WHERE birth_year = 0 OR death_year = 0)
THEN RAISE EXCEPTION 'V76 aborted: persons table contains birth_year=0 or death_year=0 rows — clean data before migrating';
END IF;
END $$;
ALTER TABLE persons ADD COLUMN birth_date date;
ALTER TABLE persons ADD COLUMN birth_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
ALTER TABLE persons ADD COLUMN death_date date;
ALTER TABLE persons ADD COLUMN death_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
UPDATE persons SET birth_date = make_date(birth_year, 1, 1), birth_date_precision = 'YEAR'
WHERE birth_year IS NOT NULL;
UPDATE persons SET death_date = make_date(death_year, 1, 1), death_date_precision = 'YEAR'
WHERE death_year IS NOT NULL;
-- Named constraints: readable Postgres error messages when violated.
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_before_death
CHECK (death_date IS NULL OR birth_date IS NULL OR birth_date <= death_date);
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_coherence
CHECK ((birth_date IS NULL) = (birth_date_precision = 'UNKNOWN'));
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_values
CHECK (birth_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_coherence
CHECK ((death_date IS NULL) = (death_date_precision = 'UNKNOWN'));
ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_values
CHECK (death_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
ALTER TABLE persons DROP COLUMN birth_year;
ALTER TABLE persons DROP COLUMN death_year;

View File

@@ -38,6 +38,7 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
@@ -75,7 +76,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
@@ -87,7 +88,7 @@ class DocumentControllerTest {
void search_undatedTrue_isReachableByAuthenticatedUser() throws Exception {
// The read GET must stay reachable for READ_ALL users — guards against a
// future refactor accidentally write-guarding the undated triage path (#668).
when(documentService.searchDocuments(any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("undated", "true"))
@@ -103,43 +104,41 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_undatedTrue_isForwardedToServiceAsTrue() throws Exception {
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
when(documentService.searchDocuments(any(), any(), any(), any()))
ArgumentCaptor<Boolean> undatedCaptor = ArgumentCaptor.forClass(Boolean.class);
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("undated", "true"))
.andExpect(status().isOk());
verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
assertThat(filtersCaptor.getValue().undated()).isTrue();
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), undatedCaptor.capture(), any());
assertThat(undatedCaptor.getValue()).isTrue();
}
@Test
@WithMockUser
void search_withoutUndatedParam_forwardsFalseToService() throws Exception {
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
when(documentService.searchDocuments(any(), any(), any(), any()))
ArgumentCaptor<Boolean> undatedCaptor = ArgumentCaptor.forClass(Boolean.class);
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk());
verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
assertThat(filtersCaptor.getValue().undated()).isFalse();
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), undatedCaptor.capture(), any());
assertThat(undatedCaptor.getValue()).isFalse();
}
@Test
@WithMockUser
void search_withStatusParam_passesItToService() throws Exception {
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
when(documentService.searchDocuments(any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), anyBoolean(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk());
verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
assertThat(filtersCaptor.getValue().status()).isEqualTo(DocumentStatus.REVIEWED);
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), anyBoolean(), any());
}
@Test
@@ -166,7 +165,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_responseContainsTotalCount() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
@@ -181,7 +180,7 @@ class DocumentControllerTest {
UUID docId = UUID.randomUUID();
var matchData = new SearchMatchData(
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null,
DatePrecision.UNKNOWN, null, null,
@@ -201,7 +200,7 @@ class DocumentControllerTest {
void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
UUID docId = UUID.randomUUID();
var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null,
DatePrecision.UNKNOWN, null, null,
@@ -224,7 +223,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_responseExposesPagingFields() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
@@ -269,7 +268,7 @@ class DocumentControllerTest {
@Test
@WithMockUser
void search_passesPageRequestToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any()))
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
@@ -277,7 +276,7 @@ class DocumentControllerTest {
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
verify(documentService).searchDocuments(any(), any(), any(), captor.capture());
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), captor.capture());
org.springframework.data.domain.Pageable pageable = captor.getValue();
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
@@ -402,7 +401,6 @@ class DocumentControllerTest {
@WithMockUser(authorities = "WRITE_ALL")
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
UUID id = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + id).with(csrf()))
.andExpect(status().isNoContent());
@@ -1210,7 +1208,7 @@ class DocumentControllerTest {
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.findIdsForFilter(any()))
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean()))
.thenReturn(List.of(id));
mockMvc.perform(get("/api/documents/ids"))
@@ -1223,33 +1221,13 @@ class DocumentControllerTest {
void getDocumentIds_passesSenderIdParamToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID senderId = UUID.randomUUID();
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
when(documentService.findIdsForFilter(any()))
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any(), anyBoolean()))
.thenReturn(List.of());
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
.andExpect(status().isOk());
verify(documentService).findIdsForFilter(filtersCaptor.capture());
assertThat(filtersCaptor.getValue().sender()).isEqualTo(senderId);
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_withoutUndatedParam_coercesNullToFalse() throws Exception {
// The controller coerces a null boxed Boolean to primitive false
// (Boolean.TRUE.equals(undated)) so the absent param never NPEs and the
// record always holds a concrete boolean.
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
when(documentService.findIdsForFilter(any()))
.thenReturn(List.of());
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isOk());
verify(documentService).findIdsForFilter(filtersCaptor.capture());
assertThat(filtersCaptor.getValue().undated()).isFalse();
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any(), anyBoolean());
}
@Test
@@ -1259,7 +1237,7 @@ class DocumentControllerTest {
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
when(documentService.findIdsForFilter(any()))
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean()))
.thenReturn(tooMany);
mockMvc.perform(get("/api/documents/ids"))
@@ -1424,16 +1402,16 @@ class DocumentControllerTest {
@Test
@WithMockUser
void density_isNeverBrowserCached() throws Exception {
void density_emitsPrivateCacheControlHeader() throws Exception {
when(documentService.getDensity(any())).thenReturn(
new DocumentDensityResult(List.of(), null, null));
// The endpoint sets no explicit Cache-Control, so Spring Security's
// default no-store directive applies — the density chart is always fresh.
mockMvc.perform(get("/api/documents/density"))
.andExpect(status().isOk())
.andExpect(header().string("Cache-Control",
"no-cache, no-store, max-age=0, must-revalidate"));
org.hamcrest.Matchers.containsString("max-age=300")))
.andExpect(header().string("Cache-Control",
org.hamcrest.Matchers.containsString("private")));
}
@Test

View File

@@ -24,7 +24,6 @@ import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@@ -123,36 +122,14 @@ class DocumentLazyLoadingTest {
savedDocument("SrDoc", "sr_doc.pdf", sender, Set.of(receiver), Set.of(tag));
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.RECEIVER, "asc", PageRequest.of(0, 20));
null, null, null, null, null, null, null, null,
DocumentSort.RECEIVER, "asc", null, false, PageRequest.of(0, 20));
assertThat(result.totalElements()).isGreaterThan(0);
assertThatCode(() ->
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
.doesNotThrowAnyException();
}
@Test
void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() {
// q + default sort + no other filters → the relevance fast path
// (relevanceSortedPageFromSql), which loads documents by id outside any
// transaction and must still deliver an initialized tags collection.
Person sender = savedPerson("Hans", "FtSender");
Tag tag = savedTag("FtTag");
savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag));
SearchFilters textOnly = new SearchFilters(
"Walter", null, null, null, null, null, null, null, null, false);
DocumentSearchResult result = documentService.searchDocuments(
textOnly, null, "DESC", PageRequest.of(0, 10));
assertThat(result.totalElements()).isEqualTo(1);
assertThatCode(() ->
result.items().forEach(i -> i.tags().size()))
.doesNotThrowAnyException();
assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag");
}
@Test
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
Person sender = savedPerson("Hans", "SsSender");
@@ -160,8 +137,8 @@ class DocumentLazyLoadingTest {
savedDocument("SsDoc", "ss_doc.pdf", sender, Set.of(), Set.of(tag));
assertThatCode(() -> documentService.searchDocuments(
noFilters(),
DocumentSort.SENDER, "asc", PageRequest.of(0, 20)))
null, null, null, null, null, null, null, null,
DocumentSort.SENDER, "asc", null, false, PageRequest.of(0, 20)))
.doesNotThrowAnyException();
}

View File

@@ -17,7 +17,6 @@ import java.util.HashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
@@ -56,8 +55,8 @@ class DocumentListItemIntegrationTest {
.build());
assertThatCode(() -> documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50)))
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)))
.doesNotThrowAnyException();
}
@@ -71,8 +70,8 @@ class DocumentListItemIntegrationTest {
.build());
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
assertThat(result.totalElements()).isGreaterThan(0);
DocumentListItem item = result.items().get(0);
@@ -92,8 +91,8 @@ class DocumentListItemIntegrationTest {
.build());
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
DocumentListItem item = result.items().stream()
.filter(i -> i.title().equals("Range Brief")).findFirst().orElseThrow();

View File

@@ -38,10 +38,7 @@ import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.springframework.dao.DataIntegrityViolationException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@@ -262,6 +259,67 @@ class DocumentRepositoryTest {
assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
}
// ─── findSinglePersonCorrespondence — DISTINCT / multi-receiver safety ────
@Test
void findSinglePersonCorrespondence_returnsExactlyOneResult_whenDocumentHasThreeReceiversAndOneMatchesPersonId() {
Person sender = personRepository.save(Person.builder()
.firstName("Hans").lastName("Müller").build());
Person receiver1 = personRepository.save(Person.builder()
.firstName("Anna").lastName("Schmidt").build());
Person receiver2 = personRepository.save(Person.builder()
.firstName("Bertha").lastName("Wagner").build());
Person receiver3 = personRepository.save(Person.builder()
.firstName("Clara").lastName("Koch").build());
// Document addressed to all three receivers
Document doc = documentRepository.save(Document.builder()
.title("Rundschreiben")
.originalFilename("rundschreiben.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.receivers(new HashSet<>(Set.of(receiver1, receiver2, receiver3)))
.documentDate(LocalDate.of(1950, 6, 1))
.build());
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
LocalDate from = LocalDate.of(1900, 1, 1);
LocalDate to = LocalDate.of(2000, 1, 1);
// Query for receiver1 — the DISTINCT must collapse the 3 JOIN rows into 1 result
List<Document> results = documentRepository.findSinglePersonCorrespondence(
receiver1.getId(), from, to, sort);
assertThat(results).hasSize(1);
assertThat(results.get(0).getId()).isEqualTo(doc.getId());
}
@Test
void findSinglePersonCorrespondence_includesDocumentsWherePerson_isSender() {
Person sender = personRepository.save(Person.builder()
.firstName("Hans").lastName("Müller").build());
Person receiver = personRepository.save(Person.builder()
.firstName("Anna").lastName("Schmidt").build());
documentRepository.save(Document.builder()
.title("Brief als Absender")
.originalFilename("brief_absender.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender)
.receivers(new HashSet<>(Set.of(receiver)))
.documentDate(LocalDate.of(1950, 6, 1))
.build());
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
LocalDate from = LocalDate.of(1900, 1, 1);
LocalDate to = LocalDate.of(2000, 1, 1);
List<Document> results = documentRepository.findSinglePersonCorrespondence(
sender.getId(), from, to, sort);
assertThat(results).hasSize(1);
}
// ─── findSegmentationQueue ────────────────────────────────────────────────
@Test
@@ -554,48 +612,6 @@ class DocumentRepositoryTest {
.isLessThanOrEqualTo(5);
}
// ─── V69 date-range CHECK constraints (#678) ──────────────────────────────
@Test
void save_acceptsRange_whenEndEqualsStart() {
// chk_meta_date_end_after_start is end >= start, so equal dates are valid.
// Real Postgres + Flyway here (H2 would not enforce the CHECK) pins the
// app guard's isBefore semantics to the actual constraint — guards drift (AC2).
LocalDate day = LocalDate.of(1917, 1, 10);
Document saved = documentRepository.saveAndFlush(Document.builder()
.title("Gleicher Tag")
.originalFilename("gleicher_tag.pdf")
.status(DocumentStatus.UPLOADED)
.documentDate(day)
.metaDatePrecision(DatePrecision.RANGE)
.metaDateEnd(day)
.build());
Document found = documentRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getDocumentDate()).isEqualTo(day);
assertThat(found.getMetaDateEnd()).isEqualTo(day);
assertThat(found.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
}
@Test
void save_rejectsRange_whenEndBeforeStart_atDbLevel() {
// The app guard normally intercepts this, so the DB CHECK never fires in practice.
// Persisting directly proves chk_meta_date_end_after_start actually rejects end < start
// (H2 would not) — if the app guard ever regresses, a bad row still can't reach the table,
// and this is exactly the violation the GlobalExceptionHandler backstop turns into a 400.
Document doc = Document.builder()
.title("Verdrehte Spanne")
.originalFilename("verdreht.pdf")
.status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1917, 1, 11))
.metaDatePrecision(DatePrecision.RANGE)
.metaDateEnd(LocalDate.of(1917, 1, 10))
.build();
assertThatThrownBy(() -> documentRepository.saveAndFlush(doc))
.isInstanceOf(DataIntegrityViolationException.class);
}
// ─── seeding helpers ─────────────────────────────────────────────────────
private Document uploaded(String title) {
@@ -624,88 +640,4 @@ class DocumentRepositoryTest {
.reviewed(reviewed)
.build();
}
// ─── searchDocumentsByPersonId (via Specification) ───────────────────────
private Page<Document> searchByPerson(Person person, LocalDate from, LocalDate to) {
Specification<Document> spec = (root, query, cb) -> {
if (query != null) query.distinct(true);
var receiversJoin = root.join("receivers", jakarta.persistence.criteria.JoinType.LEFT);
var personPredicate = cb.or(
cb.equal(root.get("sender"), person),
cb.equal(receiversJoin, person));
var predicates = new java.util.ArrayList<>(java.util.List.of(personPredicate));
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
return cb.and(predicates.toArray(new jakarta.persistence.criteria.Predicate[0]));
};
return documentRepository.findAll(spec, PageRequest.of(0, 10));
}
@Test
void searchByPersonSpec_returnsDocument_whenPersonIsSender() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document doc = documentRepository.save(Document.builder()
.title("Senderbrief").originalFilename("sender.pdf")
.status(DocumentStatus.UPLOADED).sender(person).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
}
@Test
void searchByPersonSpec_returnsDocument_whenPersonIsReceiver() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document doc = documentRepository.save(Document.builder()
.title("Empfängerbrief").originalFilename("receiver.pdf")
.status(DocumentStatus.UPLOADED)
.receivers(new java.util.HashSet<>(List.of(person))).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
}
@Test
void searchByPersonSpec_returnsDocumentOnce_whenPersonIsBothSenderAndReceiver() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document doc = documentRepository.save(Document.builder()
.title("SenderEmpfänger").originalFilename("both.pdf")
.status(DocumentStatus.UPLOADED).sender(person)
.receivers(new java.util.HashSet<>(List.of(person))).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).getId()).isEqualTo(doc.getId());
}
@Test
void searchByPersonSpec_excludesDocuments_outsideDateRange() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Document inside = documentRepository.save(Document.builder()
.title("Innen").originalFilename("inside.pdf").status(DocumentStatus.UPLOADED)
.sender(person).documentDate(LocalDate.of(1918, 6, 15)).build());
documentRepository.save(Document.builder()
.title("Außen").originalFilename("outside.pdf").status(DocumentStatus.UPLOADED)
.sender(person).documentDate(LocalDate.of(1920, 1, 1)).build());
Page<Document> result = searchByPerson(person, LocalDate.of(1914, 1, 1), LocalDate.of(1918, 12, 31));
assertThat(result.getContent()).extracting(Document::getId).containsExactly(inside.getId());
}
@Test
void searchByPersonSpec_returnsEmpty_whenNoMatchingDocuments() {
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
Person other = personRepository.save(Person.builder().lastName("Braun").build());
documentRepository.save(Document.builder()
.title("Fremder Brief").originalFilename("other.pdf")
.status(DocumentStatus.UPLOADED).sender(other).build());
Page<Document> result = searchByPerson(person, null, null);
assertThat(result.getContent()).isEmpty();
}
}

View File

@@ -21,7 +21,6 @@ import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
/**
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
@@ -62,8 +61,8 @@ class DocumentSearchPagedIntegrationTest {
@Test
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -75,8 +74,8 @@ class DocumentSearchPagedIntegrationTest {
@Test
void search_lastPartialPage_returnsRemainingItems() {
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(2, 50));
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(2, 50));
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
assertThat(result.items()).hasSize(20);
@@ -87,8 +86,8 @@ class DocumentSearchPagedIntegrationTest {
@Test
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(99, 50));
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(99, 50));
assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -100,8 +99,8 @@ class DocumentSearchPagedIntegrationTest {
// comment in DocumentService). Proves that the in-memory slice path
// returns the correct total from a real repository fetch.
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.SENDER, "asc", PageRequest.of(1, 50));
null, null, null, null, null, null, null, null,
DocumentSort.SENDER, "asc", null, false, PageRequest.of(1, 50));
assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
@@ -126,8 +125,8 @@ class DocumentSearchPagedIntegrationTest {
}
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
// Global undated count is the full undated total, independent of page size.
assertThat(result.undatedCount()).isEqualTo(undatedTotal);
@@ -154,11 +153,11 @@ class DocumentSearchPagedIntegrationTest {
}
DocumentSearchResult unfiltered = documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
DocumentSearchResult undatedOnly = documentService.searchDocuments(
noFilters().withUndated(true),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, true, PageRequest.of(0, 50));
assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
@@ -179,9 +178,9 @@ class DocumentSearchPagedIntegrationTest {
}
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters(null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
assertThat(result.undatedCount()).isZero();
}
@@ -189,11 +188,11 @@ class DocumentSearchPagedIntegrationTest {
@Test
void search_differentPagesReturnDisjointSlices() {
DocumentSearchResult page0 = documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
DocumentSearchResult page1 = documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", PageRequest.of(1, 50));
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, PageRequest.of(1, 50));
// No document id should appear on both pages — slicing must be exclusive.
var idsOnPage0 = page0.items().stream()

View File

@@ -67,8 +67,7 @@ class DocumentServiceSortTest {
.thenReturn(new PageImpl<>(List.of(newer, older)));
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.DATE, "DESC", PAGE);
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, false, PAGE);
assertThat(result.items()).hasSize(2);
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
@@ -81,12 +80,11 @@ class DocumentServiceSortTest {
UUID id1 = UUID.randomUUID();
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findByIdIn(any()))
when(documentRepository.findAllById(any()))
.thenReturn(List.of(doc(id1)));
documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, PAGE);
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE);
verify(documentRepository).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository, never()).findAllMatchingIdsByFts(anyString());
@@ -101,11 +99,10 @@ class DocumentServiceSortTest {
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, PAGE);
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE);
assertThat(result.items().get(0).id()).isEqualTo(id1);
}
@@ -119,11 +116,10 @@ class DocumentServiceSortTest {
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1)));
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
null, null, PAGE);
"Brief", null, null, null, null, null, null, null, null, null, null, false, PAGE);
assertThat(result.items().get(0).id()).isEqualTo(id1);
}
@@ -136,8 +132,8 @@ class DocumentServiceSortTest {
Pageable hugePage = org.springframework.data.domain.PageRequest.of(Integer.MAX_VALUE / 10 + 1, 10);
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, hugePage);
"Brief", null, null, null, null, null, null, null,
DocumentSort.RELEVANCE, null, null, false, hugePage);
assertThat(result.items()).isEmpty();
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
@@ -153,11 +149,11 @@ class DocumentServiceSortTest {
List<Object[]> ftsRows = new ArrayList<>();
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId)));
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, PAGE);
"Brief", null, null, null, null, null, null, null,
DocumentSort.RELEVANCE, null, null, false, PAGE);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).id()).isEqualTo(uuidId);
@@ -177,8 +173,7 @@ class DocumentServiceSortTest {
// sender filter is active → triggers in-memory path, not findFtsPageRaw
LocalDate from = LocalDate.of(1900, 1, 1);
documentService.searchDocuments(
new SearchFilters("Brief", from, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, PAGE);
"Brief", from, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE);
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
verify(documentRepository).findAllMatchingIdsByFts("Brief");

View File

@@ -5,7 +5,6 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
@@ -21,7 +20,6 @@ import org.raddatz.familienarchiv.document.MatchOffset;
import org.raddatz.familienarchiv.document.SearchMatchData;
import org.raddatz.familienarchiv.tag.TagOperator;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
@@ -30,7 +28,6 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.filestorage.FileService;
import org.raddatz.familienarchiv.tag.TagService;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
@@ -48,7 +45,6 @@ import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -76,10 +72,6 @@ class DocumentServiceTest {
@Mock AuditLogQueryService auditLogQueryService;
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
@Mock ApplicationEventPublisher eventPublisher;
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
// shared composition rather than a stub — the #726 single source of truth.
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
@InjectMocks DocumentService documentService;
// ─── deleteDocument ───────────────────────────────────────────────────────
@@ -89,7 +81,7 @@ class DocumentServiceTest {
UUID id = UUID.randomUUID();
when(documentRepository.existsById(id)).thenReturn(true);
documentService.deleteDocument(id, UUID.randomUUID());
documentService.deleteDocument(id);
verify(documentRepository).deleteById(id);
}
@@ -99,7 +91,7 @@ class DocumentServiceTest {
UUID id = UUID.randomUUID();
when(documentRepository.existsById(id)).thenReturn(false);
assertThatThrownBy(() -> documentService.deleteDocument(id, UUID.randomUUID()))
assertThatThrownBy(() -> documentService.deleteDocument(id))
.isInstanceOf(DomainException.class)
.hasMessageContaining(id.toString());
verify(documentRepository, never()).deleteById(any());
@@ -127,34 +119,23 @@ class DocumentServiceTest {
}
@Test
void getDocumentById_doesNotQueryTranscription() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Test").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
documentService.getDocumentById(id);
verifyNoInteractions(transcriptionBlockQueryService);
}
@Test
void getDocumentDetail_setsHasTranscriptionTrue_whenBlocksExist() {
void getDocumentById_setsHasTranscriptionTrue_whenBlocksExist() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Test").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(true);
assertThat(documentService.getDocumentDetail(id).isHasTranscription()).isTrue();
assertThat(documentService.getDocumentById(id).isHasTranscription()).isTrue();
}
@Test
void getDocumentDetail_setsHasTranscriptionFalse_whenNoBlocksExist() {
void getDocumentById_setsHasTranscriptionFalse_whenNoBlocksExist() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("Test").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(transcriptionBlockQueryService.hasBlocks(id)).thenReturn(false);
assertThat(documentService.getDocumentDetail(id).isHasTranscription()).isFalse();
assertThat(documentService.getDocumentById(id).isHasTranscription()).isFalse();
}
// ─── updateDocument ───────────────────────────────────────────────────────
@@ -210,12 +191,10 @@ class DocumentServiceTest {
// Editing a doc (e.g. fixing a location typo) without touching the precision
// controls must NOT fabricate a precision. The form omits the three precision
// fields → they arrive null on the DTO → the stored values must be preserved.
// Stored combo is RANGE + end: the only DB-valid way to have a non-null end
// (chk_meta_date_end_only_for_range), so the carried-over state passes the guard.
UUID id = UUID.randomUUID();
Document doc = Document.builder()
.id(id)
.metaDatePrecision(DatePrecision.RANGE)
.metaDatePrecision(DatePrecision.MONTH)
.metaDateEnd(LocalDate.of(1916, 6, 30))
.metaDateRaw("Juni 1916")
.receivers(new HashSet<>())
@@ -229,329 +208,11 @@ class DocumentServiceTest {
documentService.updateDocument(id, dto, null, null);
assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.MONTH);
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1916, 6, 30));
assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916");
}
// ─── updateDocument save-time auto-title regeneration (#726) ──────────────
//
// Exact old-vs-new comparison: the title is the catalog auto-title iff the submitted
// title equals what the factory builds from the CURRENTLY-persisted state. The edit form
// round-trips the stored title verbatim when untouched, so an equal submission means the
// user did not type over it. makeStored() seeds index/date/precision/location and sets the
// stored title to the matching auto-title, mirroring a freshly-imported row.
private Document makeStored(String index, LocalDate date, DatePrecision precision, String location) {
Document doc = Document.builder()
.id(UUID.randomUUID())
.originalFilename(index)
.documentDate(date)
.metaDatePrecision(precision)
.location(location)
.receivers(new HashSet<>())
.tags(new HashSet<>())
.build();
doc.setTitle(documentTitleFactory.build(doc));
return doc;
}
/** A DTO that round-trips the stored auto-title untouched, with new date/precision/location. */
private static DocumentUpdateDTO editDto(String submittedTitle, LocalDate date,
DatePrecision precision, String location) {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle(submittedTitle);
dto.setDocumentDate(date);
dto.setMetaDatePrecision(precision);
dto.setLocation(location);
return dto;
}
private Document runUpdate(Document stored, DocumentUpdateDTO dto) throws Exception {
when(documentRepository.findById(stored.getId())).thenReturn(Optional.of(stored));
when(documentRepository.save(any())).thenReturn(stored);
documentService.updateDocument(stored.getId(), dto, null, null);
return stored;
}
@Test
void updateDocument_regeneratesAutoTitle_whenDateChanges() throws Exception {
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
// title untouched ("C-0029 2028 Berlin"), date corrected to 1928
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo("C-0029 1928 Berlin");
}
@Test
void updateDocument_keepsHandWrittenTitle_whenDateChanges() throws Exception {
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
stored.setTitle("C-0029 Brief an Mutter"); // hand-written, ≠ auto-title
DocumentUpdateDTO dto = editDto("C-0029 Brief an Mutter", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, null);
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo("C-0029 Brief an Mutter");
}
@Test
void updateDocument_freshlyTypedTitleWins_overRegeneration() throws Exception {
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
// user changed the date AND typed a new title in the same save
DocumentUpdateDTO dto = editDto("Geburtsanzeige", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo("Geburtsanzeige");
}
@Test
void updateDocument_regeneratesWithNewDateAndLocation() throws Exception {
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "München");
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo("C-0029 1928 München");
}
@Test
void updateDocument_dropsTrailingLocationSegment_whenLocationCleared() throws Exception {
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
// location cleared (null), title untouched
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo("C-0029 1928");
}
@Test
void updateDocument_regeneratedTitle_doesNotContainOldDate() throws Exception {
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
runUpdate(stored, dto);
assertThat(stored.getTitle()).doesNotContain("2028");
}
@Test
void updateDocument_relabelsOnPrecisionChange_yearToDay() throws Exception {
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
// stored auto-title "C-0029 1928"; set a full day at DAY precision
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 15), DatePrecision.DAY, null);
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo("C-0029 15. Januar 1928");
}
@Test
void updateDocument_populatesTitle_whenDateAddedToUnknownRow() throws Exception {
Document stored = makeStored("C-0029", null, DatePrecision.UNKNOWN, null);
// stored auto-title is just "C-0029"; add a 1928 YEAR date
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo("C-0029 1928");
}
@Test
void updateDocument_roundTripsSeasonLabel() throws Exception {
Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
stored.setMetaDateRaw("Frühling 1943");
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 Frühling 1943"
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
dto.setMetaDateRaw("Frühling 1943");
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo("C-0029 Frühling 1943");
}
@Test
void updateDocument_carriesStoredPrecisionAndRaw_whenDtoOmitsThem() throws Exception {
// Only the year changes; precision/end/raw are omitted from the DTO, so projectedState
// must carry them from the entity (exercises the skip-null effective* resolvers).
Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
stored.setMetaDateRaw("Frühling 1943");
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 Frühling 1943"
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1944, 4, 1), null, null);
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo("C-0029 Frühling 1944");
}
@Test
void updateDocument_roundTripsRangeLabel_atSaveTime() throws Exception {
Document stored = Document.builder()
.id(UUID.randomUUID())
.originalFilename("C-0029")
.documentDate(LocalDate.of(1917, 1, 10))
.metaDatePrecision(DatePrecision.RANGE)
.metaDateEnd(LocalDate.of(1917, 1, 11))
.receivers(new HashSet<>())
.tags(new HashSet<>())
.build();
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 10.11. Jan. 1917"
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle(stored.getTitle());
dto.setDocumentDate(LocalDate.of(1918, 1, 10));
dto.setMetaDatePrecision(DatePrecision.RANGE);
dto.setMetaDateEnd(LocalDate.of(1918, 1, 11));
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo("C-0029 10.11. Jan. 1918");
}
@Test
void updateDocument_doesNotRegenerateToBlank_whenSubmittedTitleEmpty() throws Exception {
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
DocumentUpdateDTO dto = editDto("", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
runUpdate(stored, dto);
assertThat(stored.getTitle()).isNotBlank();
}
@Test
void updateDocument_treatsFileReplacedDoc_asManual() throws Exception {
// originalFilename was reassigned by an earlier file-replace, so the stored title (built
// at import from the old index) no longer matches build(currentState) → treated as manual.
Document stored = makeStored("scan_2024.pdf", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
stored.setTitle("C-0029 1928 Berlin"); // legacy import title, ≠ build("scan_2024.pdf"…)
DocumentUpdateDTO dto = editDto("C-0029 1928 Berlin", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, "Berlin");
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo("C-0029 1928 Berlin");
}
@Test
void updateDocument_idempotent_whenNothingChanges() throws Exception {
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
String before = stored.getTitle();
DocumentUpdateDTO dto = editDto(before, LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
runUpdate(stored, dto);
assertThat(stored.getTitle()).isEqualTo(before);
}
// ─── updateDocument date-range validation (#678) ──────────────────────────
/** Builds a stored doc ready for an updateDocument call (collections initialised). */
private static Document docForRangeUpdate(UUID id) {
return Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
}
private static DocumentUpdateDTO rangeDto(LocalDate start, LocalDate end) {
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setDocumentDate(start);
dto.setMetaDatePrecision(DatePrecision.RANGE);
dto.setMetaDateEnd(end);
return dto;
}
@Test
void updateDocument_rejectsRange_whenEndBeforeStart() {
UUID id = UUID.randomUUID();
Document doc = docForRangeUpdate(id);
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
DocumentUpdateDTO dto = rangeDto(LocalDate.of(1917, 1, 11), LocalDate.of(1917, 1, 10));
assertThatThrownBy(() -> documentService.updateDocument(id, dto, null, null))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(documentRepository, never()).save(any());
}
@Test
void updateDocument_acceptsRange_whenEndEqualsStart() throws Exception {
// AC2: the DB CHECK is end >= start, so equal dates are valid.
UUID id = UUID.randomUUID();
Document doc = docForRangeUpdate(id);
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenReturn(doc);
LocalDate same = LocalDate.of(1917, 1, 10);
documentService.updateDocument(id, rangeDto(same, same), null, null);
assertThat(doc.getMetaDateEnd()).isEqualTo(same);
verify(documentRepository, atLeastOnce()).save(any());
}
@Test
void updateDocument_acceptsRange_whenEndAfterStart() throws Exception {
UUID id = UUID.randomUUID();
Document doc = docForRangeUpdate(id);
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenReturn(doc);
documentService.updateDocument(id,
rangeDto(LocalDate.of(1917, 1, 10), LocalDate.of(1917, 1, 11)), null, null);
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11));
verify(documentRepository, atLeastOnce()).save(any());
}
@Test
void updateDocument_acceptsRange_whenEndIsNull_openEnded() throws Exception {
// AC3: an open-ended range (no end) is valid.
UUID id = UUID.randomUUID();
Document doc = docForRangeUpdate(id);
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenReturn(doc);
documentService.updateDocument(id,
rangeDto(LocalDate.of(1917, 1, 10), null), null, null);
verify(documentRepository, atLeastOnce()).save(any());
}
@Test
void updateDocument_acceptsRange_whenStartNullAndEndSet() throws Exception {
// AC4: mirrors the DB "meta_date IS NULL" escape — must NOT reject (and must not NPE).
UUID id = UUID.randomUUID();
Document doc = docForRangeUpdate(id);
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenReturn(doc);
documentService.updateDocument(id,
rangeDto(null, LocalDate.of(1917, 1, 11)), null, null);
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11));
verify(documentRepository, atLeastOnce()).save(any());
}
@Test
void updateDocument_rejectsEndDate_whenPrecisionNotRange() {
// AC6: an end date only makes sense for RANGE (mirrors chk_meta_date_end_only_for_range).
// API-only — the edit form clears the end field off-RANGE — so close the 500 class here too.
UUID id = UUID.randomUUID();
Document doc = docForRangeUpdate(id);
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setDocumentDate(LocalDate.of(1917, 1, 10));
dto.setMetaDatePrecision(DatePrecision.MONTH);
dto.setMetaDateEnd(LocalDate.of(1917, 1, 31));
assertThatThrownBy(() -> documentService.updateDocument(id, dto, null, null))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(documentRepository, never()).save(any());
}
// ─── deleteTagCascading ───────────────────────────────────────────────────
@Test
@@ -697,59 +358,6 @@ class DocumentServiceTest {
verify(documentVersionService).recordVersion(any(Document.class));
}
// ─── backfillTitles — one-time stale-title cleanup (#726, FR-003) ─────────
@Test
void backfillTitles_rewritesStaleAutoTitle_andCountsIt() {
Document stale = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
stale.setTitle("C-0029 2028 Berlin"); // stale stored title (date typo never fixed)
when(documentRepository.findAll()).thenReturn(List.of(stale));
when(documentRepository.save(any())).thenReturn(stale);
int count = documentService.backfillTitles();
assertThat(count).isEqualTo(1);
assertThat(stale.getTitle()).isEqualTo("C-0029 1928 Berlin");
verify(documentRepository).save(stale);
}
@Test
void backfillTitles_skipsProse() {
Document prose = makeStored("C-0030", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
prose.setTitle("C-0030 Brief an Mutter");
when(documentRepository.findAll()).thenReturn(List.of(prose));
int count = documentService.backfillTitles();
assertThat(count).isZero();
assertThat(prose.getTitle()).isEqualTo("C-0030 Brief an Mutter");
verify(documentRepository, never()).save(any());
}
@Test
void backfillTitles_isIdempotent_forAlreadyCorrectTitle() {
Document fresh = makeStored("C-0031", LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
// title already equals build(current state) → nothing to do
when(documentRepository.findAll()).thenReturn(List.of(fresh));
int count = documentService.backfillTitles();
assertThat(count).isZero();
verify(documentRepository, never()).save(any());
}
@Test
void backfillTitles_neverRecordsVersions() {
Document stale = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
stale.setTitle("C-0029 2028 Berlin");
when(documentRepository.findAll()).thenReturn(List.of(stale));
when(documentRepository.save(any())).thenReturn(stale);
documentService.backfillTitles();
verify(documentVersionService, never()).recordVersion(any());
}
// ─── thumbnail dispatch ───────────────────────────────────────────────────
@Test
@@ -1397,6 +1005,53 @@ class DocumentServiceTest {
.isEqualTo("19650332_Mueller_Hans");
}
// ─── getConversationFiltered ───────────────────────────────────────────────
@Test
void getConversationFiltered_passesGivenDates_whenFromAndToAreProvided() {
UUID senderId = UUID.randomUUID();
UUID receiverId = UUID.randomUUID();
LocalDate from = LocalDate.of(1940, 1, 1);
LocalDate to = LocalDate.of(1960, 12, 31);
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
when(documentRepository.findConversation(senderId, receiverId, from, to, sort))
.thenReturn(List.of());
documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
verify(documentRepository).findConversation(senderId, receiverId, from, to, sort);
}
@Test
void getConversationFiltered_usesMinDateForFrom_whenFromIsNull() {
UUID senderId = UUID.randomUUID();
UUID receiverId = UUID.randomUUID();
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
.thenReturn(List.of());
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
ArgumentCaptor<LocalDate> fromCaptor = ArgumentCaptor.forClass(LocalDate.class);
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), fromCaptor.capture(), any(LocalDate.class), eq(sort));
assertThat(fromCaptor.getValue()).isEqualTo(LocalDate.parse("0000-01-01"));
}
@Test
void getConversationFiltered_usesTodayForTo_whenToIsNull() {
UUID senderId = UUID.randomUUID();
UUID receiverId = UUID.randomUUID();
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
.thenReturn(List.of());
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
ArgumentCaptor<LocalDate> toCaptor = ArgumentCaptor.forClass(LocalDate.class);
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), toCaptor.capture(), eq(sort));
assertThat(toCaptor.getValue()).isEqualTo(LocalDate.now());
}
// ─── updateDocumentTags — empty tag in list ───────────────────────────────
@Test
@@ -1775,9 +1430,8 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(
noFilters(),
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(1, 50));
documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
@@ -1789,9 +1443,8 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(
noFilters(),
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(3, 25));
documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(3, 25));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
@@ -1806,9 +1459,8 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(0, 50));
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 50));
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isZero();
@@ -1823,9 +1475,8 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(0, 5));
documentService.searchDocuments(null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate");
@@ -1847,9 +1498,8 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(
noFilters(),
DocumentSort.DATE, "ASC", org.springframework.data.domain.PageRequest.of(0, 5));
documentService.searchDocuments(null, null, null, null, null, null, null, null,
DocumentSort.DATE, "ASC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate");
@@ -1869,9 +1519,8 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(
noFilters(),
DocumentSort.UPDATED_AT, "DESC", org.springframework.data.domain.PageRequest.of(0, 5));
documentService.searchDocuments(null, null, null, null, null, null, null, null,
DocumentSort.UPDATED_AT, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
assertThat(captor.getValue().getSort())
@@ -1894,9 +1543,8 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", org.springframework.data.domain.PageRequest.of(1, 50));
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isEqualTo(1);
@@ -1919,9 +1567,8 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", org.springframework.data.domain.PageRequest.of(10, 50));
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(10, 50));
assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(30L);
@@ -1934,8 +1581,7 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(
new SearchFilters(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, false), null, null, UNPAGED);
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, false, UNPAGED);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
}
@@ -1945,8 +1591,7 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(
noFilters(), null, null, UNPAGED);
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
}
@@ -1982,6 +1627,35 @@ class DocumentServiceTest {
.isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
}
// ─── getConversationFiltered (single-person mode) ─────────────────────────
@Test
void getConversationFiltered_callsSinglePersonQuery_whenReceiverIdIsNull() {
UUID personId = UUID.randomUUID();
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
when(documentRepository.findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort)))
.thenReturn(List.of());
documentService.getConversationFiltered(personId, null, null, null, sort);
verify(documentRepository).findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort));
verify(documentRepository, never()).findConversation(any(), any(), any(), any(), any());
}
@Test
void getConversationFiltered_callsBilateralQuery_whenReceiverIdIsSet() {
UUID senderId = UUID.randomUUID();
UUID receiverId = UUID.randomUUID();
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort)))
.thenReturn(List.of());
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort));
verify(documentRepository, never()).findSinglePersonCorrespondence(any(), any(), any(), any());
}
// ─── searchDocuments — SENDER sort includes documents with null sender ─────
@Test
@@ -1995,8 +1669,7 @@ class DocumentServiceTest {
.thenReturn(List.of(withSender, noSender));
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.SENDER, "asc", UNPAGED);
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
assertThat(result.items()).hasSize(2);
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
@@ -2016,8 +1689,7 @@ class DocumentServiceTest {
.thenReturn(List.of(noReceivers, withReceiver));
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.RECEIVER, "asc", UNPAGED);
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, false, UNPAGED);
assertThat(result.items()).extracting(DocumentListItem::title)
.containsExactly("Has Receiver", "No Receivers");
@@ -2050,8 +1722,7 @@ class DocumentServiceTest {
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.SENDER, "asc", UNPAGED);
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
// Bob's group precedes Anna's group (ASC by sender). The sort is stable, so
// within each group the input order is preserved (undatedBob, datedBob for Bob;
@@ -2082,8 +1753,7 @@ class DocumentServiceTest {
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.SENDER, "desc", UNPAGED);
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "desc", null, false, UNPAGED);
// Anna's group precedes Bob's (DESC by sender); undated stays inside its group.
assertThat(result.items()).extracting(DocumentListItem::title)
@@ -2106,8 +1776,7 @@ class DocumentServiceTest {
.thenReturn(List.of(undatedFromAlice));
DocumentSearchResult result = documentService.searchDocuments(
noFilters().withUndated(true),
DocumentSort.SENDER, "asc", UNPAGED);
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, true, UNPAGED);
// The in-memory path queried via a Specification (built by buildSearchSpec with
// undatedOnly(true)) rather than skipping straight to a sorted findAll.
@@ -2123,9 +1792,8 @@ class DocumentServiceTest {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of());
documentService.searchDocuments(
new SearchFilters("brief", null, null, null, null, null, null, null, null, true),
DocumentSort.RELEVANCE, null, UNPAGED);
documentService.searchDocuments("brief", null, null, null, null, null, null, null,
DocumentSort.RELEVANCE, null, null, true, UNPAGED);
// The FTS-id path (buildSearchSpec) ran; the raw-page SQL shortcut did not.
verify(documentRepository).findAllMatchingIdsByFts("brief");
@@ -2148,8 +1816,7 @@ class DocumentServiceTest {
.thenReturn(List.of(docNullName, docSmith));
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
DocumentSort.SENDER, "asc", UNPAGED);
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
assertThat(result.items()).extracting(DocumentListItem::title)
@@ -2168,12 +1835,11 @@ class DocumentServiceTest {
List<Object[]> ftsRows = new java.util.ArrayList<>();
ftsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, UNPAGED);
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, UNPAGED);
assertThat(result.items()).hasSize(1);
SearchMatchData md = result.items().get(0).matchData();
@@ -2187,8 +1853,7 @@ class DocumentServiceTest {
.thenReturn(new PageImpl<>(List.of()));
DocumentSearchResult result = documentService.searchDocuments(
noFilters(),
null, null, UNPAGED);
null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED);
assertThat(result.items()).isEmpty();
}
@@ -2204,12 +1869,11 @@ class DocumentServiceTest {
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
DocumentSort.RELEVANCE, null, UNPAGED);
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, UNPAGED);
SearchMatchData md = result.items().get(0).matchData();
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
@@ -2726,7 +2390,7 @@ class DocumentServiceTest {
.thenReturn(List.of(d1, d2));
List<UUID> result = documentService.findIdsForFilter(
noFilters());
null, null, null, null, null, null, null, null, null, false);
assertThat(result).containsExactly(d1.getId(), d2.getId());
}
@@ -2741,7 +2405,7 @@ class DocumentServiceTest {
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
documentService.findIdsForFilter(
new SearchFilters(null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR, false));
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR, false);
// Spec built without throwing → OR branch was exercised. Coverage gain
// is in not-throwing on the OR-specific code path; the actual SQL is
@@ -2754,7 +2418,7 @@ class DocumentServiceTest {
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
List<UUID> result = documentService.findIdsForFilter(
new SearchFilters("xyz", null, null, null, null, null, null, null, null, false));
"xyz", null, null, null, null, null, null, null, null, false);
assertThat(result).isEmpty();
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));

View File

@@ -1,90 +0,0 @@
package org.raddatz.familienarchiv.document;
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.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;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end backfill against a real Postgres (#726, FR-003). H2 is unusable here — the
* {@code title} column is NOT NULL and the title-sync semantics depend on that — so this pins the
* behaviour on {@code postgres:16-alpine}: a stale auto-title is rewritten, the sweep is
* idempotent, prose is left alone, and the mechanical rename writes no {@code document_versions}
* rows. Permission enforcement (401/403) is covered faster by the {@code @WebMvcTest} slice in
* {@code AdminControllerTest}.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class DocumentTitleBackfillIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired DocumentService documentService;
@Autowired DocumentRepository documentRepository;
@Autowired DocumentVersionRepository documentVersionRepository;
private Document persist(String index, String title, LocalDate date, DatePrecision precision, String location) {
return documentRepository.save(Document.builder()
.originalFilename(index)
.title(title)
.documentDate(date)
.metaDatePrecision(precision)
.location(location)
.status(DocumentStatus.PLACEHOLDER)
.build());
}
@Test
void backfill_rewritesStaleAutoTitle() {
Document stale = persist("C-0029", "C-0029 2028 Berlin",
LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
int count = documentService.backfillTitles();
assertThat(count).isEqualTo(1); // exactly the one stale row seeded (clean test DB)
assertThat(documentRepository.findById(stale.getId()).orElseThrow().getTitle())
.isEqualTo("C-0029 1928 Berlin");
}
@Test
void backfill_isIdempotent_secondRunChangesNothing() {
persist("C-0029", "C-0029 2028 Berlin", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
documentService.backfillTitles();
int secondRun = documentService.backfillTitles();
assertThat(secondRun).isZero();
}
@Test
void backfill_skipsProse() {
Document prose = persist("C-0030", "C-0030 Brief an Mutter",
LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
documentService.backfillTitles();
assertThat(documentRepository.findById(prose.getId()).orElseThrow().getTitle())
.isEqualTo("C-0030 Brief an Mutter");
}
@Test
void backfill_addsNoDocumentVersionRows() {
persist("C-0029", "C-0029 2028 Berlin", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
long versionsBefore = documentVersionRepository.count();
documentService.backfillTitles();
assertThat(documentVersionRepository.count()).isEqualTo(versionsBefore);
}
}

View File

@@ -1,175 +0,0 @@
package org.raddatz.familienarchiv.document;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The backfill overwrite heuristic (FR-004) in isolation — every emittable date-label form is
* recognised, prose is left alone, and a regex-metacharacter index is matched literally without
* hanging. The exact label spellings mirror {@code docs/date-label-fixtures.json}.
*/
class DocumentTitleBackfillMatcherTest {
private static boolean overwritable(String title, String location) {
return DocumentTitleBackfillMatcher.isOverwritable(title, "C-0029", location);
}
// ─── each date-label form (index + form) is overwritable ──────────────────
@Test
void year_form() {
assertThat(overwritable("C-0029 1916", null)).isTrue();
}
@Test
void approx_form() {
assertThat(overwritable("C-0029 ca. 1920", null)).isTrue();
}
@Test
void month_form() {
assertThat(overwritable("C-0029 Juni 1916", null)).isTrue();
}
@Test
void day_form() {
assertThat(overwritable("C-0029 24. Dezember 1943", null)).isTrue();
}
@Test
void season_form() {
assertThat(overwritable("C-0029 Sommer 1916", null)).isTrue();
}
@Test
void unknown_label_form() {
assertThat(overwritable("C-0029 Datum unbekannt", null)).isTrue();
}
@Test
void range_same_month_form() {
assertThat(overwritable("C-0029 10.11. Jan. 1917", null)).isTrue();
}
@Test
void range_cross_month_form() {
assertThat(overwritable("C-0029 30. Jan. 2. Feb. 1917", null)).isTrue();
}
@Test
void range_cross_year_form() {
assertThat(overwritable("C-0029 30. Dez. 1916 2. Jan. 1917", null)).isTrue();
}
@Test
void range_single_day_form() {
assertThat(overwritable("C-0029 10. Jan. 1917", null)).isTrue();
}
@Test
void range_open_form() {
assertThat(overwritable("C-0029 ab 10. Jan. 1917", null)).isTrue();
}
// ─── date label + trailing location (any location) ────────────────────────
@Test
void date_form_with_trailing_location() {
assertThat(overwritable("C-0029 1916 Berlin", null)).isTrue();
}
@Test
void range_with_internal_separator_plus_trailing_location() {
// The range label itself contains " "; the trailing " Berlin" must still be peeled.
assertThat(overwritable("C-0029 30. Jan. 2. Feb. 1917 Berlin", null)).isTrue();
}
// ─── index-only and index+location cases ──────────────────────────────────
@Test
void exactly_index() {
assertThat(overwritable("C-0029", null)).isTrue();
}
@Test
void index_plus_location_equal_to_current() {
assertThat(overwritable("C-0029 Berlin", "Berlin")).isTrue();
}
// ─── prose is left untouched ──────────────────────────────────────────────
@Test
void prose_segment_not_matching_location_is_skipped() {
assertThat(overwritable("C-0029 Brief an Mutter", "Berlin")).isFalse();
}
@Test
void location_only_segment_is_skipped_when_no_current_location() {
// No date label, and the doc has no location to compare against → cannot prove machine.
assertThat(overwritable("C-0029 Berlin", null)).isFalse();
}
@Test
void title_not_starting_with_index_is_skipped() {
assertThat(overwritable("Ganz anderer Titel", null)).isFalse();
}
// ─── near-miss: shapes that look almost machine-built but are not ──────────
@Test
void ascii_hyphen_instead_of_en_dash_separator_is_skipped() {
// The separator is " " (en dash); a plain " - " is not the machine separator.
assertThat(overwritable("C-0029 - 1916", null)).isFalse();
}
@Test
void date_label_without_separator_before_trailing_text_is_skipped() {
// "1916 Berlin" is not a date label and is not joined by " "; prose, not machine.
assertThat(overwritable("C-0029 1916 Berlin", null)).isFalse();
}
@Test
void year_with_trailing_letters_is_not_a_year_label() {
assertThat(overwritable("C-0029 1916er Brief", null)).isFalse();
}
@Test
void index_immediately_followed_by_text_without_separator_is_skipped() {
assertThat(overwritable("C-0029x 1916", null)).isFalse();
}
// ─── fail-closed guards ───────────────────────────────────────────────────
@Test
void null_title_is_not_overwritable() {
assertThat(DocumentTitleBackfillMatcher.isOverwritable(null, "C-0029", null)).isFalse();
}
@Test
void null_index_is_not_overwritable() {
assertThat(DocumentTitleBackfillMatcher.isOverwritable("C-0029 1916", null, null)).isFalse();
}
@Test
void blank_index_is_not_overwritable() {
assertThat(DocumentTitleBackfillMatcher.isOverwritable(" 1916", " ", null)).isFalse();
}
// ─── ReDoS / regex-metacharacter index is matched literally and terminates ─
@Test
@Timeout(value = 5, unit = TimeUnit.SECONDS)
void index_with_regex_metacharacters_is_matched_literally_and_terminates() {
String hostileIndex = "C-0029(.*).pdf";
// Literal prefix → matches; trailing date label → overwritable. Must not hang.
assertThat(DocumentTitleBackfillMatcher.isOverwritable(
hostileIndex + " 1916", hostileIndex, null)).isTrue();
// A title that does NOT start with the literal hostile index is skipped, also fast.
assertThat(DocumentTitleBackfillMatcher.isOverwritable(
"C-0029 1916", hostileIndex, null)).isFalse();
}
}

View File

@@ -1,89 +0,0 @@
package org.raddatz.familienarchiv.document;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The auto-title composition {@code {index} {dateLabel} {location}} in isolation.
* The honest date-label forms themselves are pinned by {@link DocumentTitleFormatterTest}
* against the shared #666 fixture; here we assert only how the factory composes the
* three segments and which segments it omits.
*/
class DocumentTitleFactoryTest {
private final DocumentTitleFactory factory = new DocumentTitleFactory();
private static Document.DocumentBuilder doc(String index) {
return Document.builder()
.originalFilename(index)
.metaDatePrecision(DatePrecision.UNKNOWN);
}
@Test
void index_only_when_no_date_and_no_location() {
assertThat(factory.build(doc("C-0029").build())).isEqualTo("C-0029");
}
@Test
void index_and_year_date() {
Document d = doc("C-0029")
.documentDate(LocalDate.of(1928, 1, 15))
.metaDatePrecision(DatePrecision.YEAR)
.build();
assertThat(factory.build(d)).isEqualTo("C-0029 1928");
}
@Test
void index_date_and_location() {
Document d = doc("C-0029")
.documentDate(LocalDate.of(1928, 1, 15))
.metaDatePrecision(DatePrecision.YEAR)
.location("Berlin")
.build();
assertThat(factory.build(d)).isEqualTo("C-0029 1928 Berlin");
}
@Test
void location_without_date_attaches_directly_to_index() {
Document d = doc("C-0029").location("Berlin").build();
assertThat(factory.build(d)).isEqualTo("C-0029 Berlin");
}
@Test
void unknown_precision_omits_the_date_segment() {
Document d = doc("C-0029")
.documentDate(LocalDate.of(1928, 1, 15))
.metaDatePrecision(DatePrecision.UNKNOWN)
.build();
assertThat(factory.build(d)).isEqualTo("C-0029");
}
@Test
void blank_location_is_omitted() {
Document d = doc("C-0029")
.documentDate(LocalDate.of(1928, 1, 15))
.metaDatePrecision(DatePrecision.YEAR)
.location(" ")
.build();
assertThat(factory.build(d)).isEqualTo("C-0029 1928");
}
@Test
void bare_document_with_null_index_builds_empty_string_not_npe() {
// originalFilename is NOT NULL in production; the guard keeps a synthetic/partial entity
// from tripping StringBuilder(null) with an opaque NPE.
assertThat(factory.build(Document.builder().build())).isEqualTo("");
}
@Test
void day_precision_renders_the_full_german_label() {
Document d = doc("C-0029")
.documentDate(LocalDate.of(1928, 1, 15))
.metaDatePrecision(DatePrecision.DAY)
.build();
assertThat(factory.build(d)).isEqualTo("C-0029 15. Januar 1928");
}
}

View File

@@ -1,17 +0,0 @@
package org.raddatz.familienarchiv.document;
/** Test fixtures for {@link SearchFilters}. */
final class SearchFiltersFixtures {
private SearchFiltersFixtures() {}
/**
* A {@link SearchFilters} with no predicate active — the common search-test
* baseline. Combine with {@code .withUndated(true)} for the undated-only case;
* construct {@code new SearchFilters(...)} directly when a test pins a specific
* field, so the intent stays visible at the call site.
*/
static SearchFilters noFilters() {
return new SearchFilters(null, null, null, null, null, null, null, null, null, false);
}
}

View File

@@ -1,123 +0,0 @@
package org.raddatz.familienarchiv.document;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository;
import org.raddatz.familienarchiv.tag.TagService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
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;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
* #730 — tag-name resolution against a real Postgres. A mocked repo can't prove the two things that
* actually break: that {@code findAllByNameIgnoreCase} folds case the way Postgres {@code LOWER()}
* does (critical for umlauts like {@code ü}), and that saving a document tagged with a case-colliding
* tag no longer throws {@code NonUniqueResultException}. H2 folds case differently, so this pins the
* behaviour on {@code postgres:16-alpine}. The four-branch resolution logic itself is covered faster
* by the mocked {@code TagServiceTest}.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class TagCaseCollisionIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired DocumentService documentService;
@Autowired DocumentRepository documentRepository;
@Autowired TagRepository tagRepository;
@Autowired TagService tagService;
private Tag persistTag(String name, String sourceRef, UUID parentId) {
return tagRepository.save(Tag.builder().name(name).sourceRef(sourceRef).parentId(parentId).build());
}
private Document persistDocTaggedWith(Tag tag) {
return documentRepository.save(Document.builder()
.originalFilename("C-7301")
.title("Weihnachtsbrief")
.documentDate(LocalDate.of(1928, 1, 1))
.metaDatePrecision(DatePrecision.YEAR)
.status(DocumentStatus.UPLOADED)
.tags(new HashSet<>(Set.of(tag)))
.build());
}
@Test
void updateDocument_succeedsAndKeepsExactChildTag_whenTaggedWithCaseCollidingChild() throws Exception {
Tag parent = persistTag("Weihnachten", "Weihnachten", null);
Tag child = persistTag("weihnachten", "Weihnachten/weihnachten", parent.getId());
Document doc = persistDocTaggedWith(child);
DocumentUpdateDTO dto = new DocumentUpdateDTO();
dto.setTitle("Weihnachtsbrief");
dto.setDocumentDate(LocalDate.of(1930, 1, 1)); // change the date — the field that 500'd on staging
dto.setMetaDatePrecision(DatePrecision.YEAR);
dto.setTags("weihnachten"); // the edit form round-trips the stored child name
assertThatCode(() -> documentService.updateDocument(doc.getId(), dto, null, null))
.doesNotThrowAnyException();
Set<Tag> tags = documentRepository.findById(doc.getId()).orElseThrow().getTags();
assertThat(tags).hasSize(1);
assertThat(tags.iterator().next().getId()).isEqualTo(child.getId()); // child kept, not the parent
}
@Test
void findOrCreate_resolvesUmlautCollisionDeterministically_withoutThrow() {
// The regression catcher: a plain-ASCII pair would stay green even if Postgres folded ü wrongly.
Tag parent = persistTag("Glückwünsche", "Glückwünsche", null);
Tag child = persistTag("glückwünsche", "Glückwünsche/glückwünsche", parent.getId());
// Proof that real Postgres LOWER() folds the umlaut so both rows match case-insensitively.
// Query with the UPPERCASE form findOrCreate actually passes — folding LOWER('GLÜCKWÜNSCHE')
// against LOWER(name) is the exact step under test; a lowercase probe wouldn't exercise it.
assertThat(tagRepository.findAllByNameIgnoreCase("GLÜCKWÜNSCHE")).hasSize(2);
// No exact-case "GLÜCKWÜNSCHE" row exists → resolution falls through to the case-insensitive
// branch with two candidates and must pick the lowest id deterministically, never throwing.
UUID expected = List.of(parent, child).stream().min(Comparator.comparing(Tag::getId)).orElseThrow().getId();
Tag first = tagService.findOrCreate("GLÜCKWÜNSCHE");
Tag second = tagService.findOrCreate("GLÜCKWÜNSCHE");
assertThat(first.getId()).isEqualTo(expected);
assertThat(second.getId()).isEqualTo(first.getId());
}
@Test
void bulkEdit_resolvesCaseCollidingTagThroughFindOrCreate_withoutThrow() {
// Bulk-edit shares resolveTags → findOrCreate; this guards a future refactor that bypasses it.
Tag parent = persistTag("Weihnachten", "Weihnachten", null);
Tag child = persistTag("weihnachten", "Weihnachten/weihnachten", parent.getId());
Document doc = documentRepository.save(Document.builder()
.originalFilename("C-7302")
.title("Brief")
.status(DocumentStatus.UPLOADED)
.build());
DocumentBulkEditDTO dto = new DocumentBulkEditDTO();
dto.setTagNames(List.of("weihnachten"));
assertThatCode(() -> documentService.applyBulkEditToDocument(doc.getId(), dto, null))
.doesNotThrowAnyException();
Set<Tag> tags = documentRepository.findById(doc.getId()).orElseThrow().getTags();
assertThat(tags).hasSize(1);
assertThat(tags.iterator().next().getId()).isEqualTo(child.getId());
}
}

View File

@@ -94,15 +94,6 @@ class CommentControllerTest {
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void postBlockComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception {
@@ -151,16 +142,6 @@ class CommentControllerTest {
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void replyToBlockComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
UUID blockId = UUID.randomUUID();
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
@@ -200,14 +181,6 @@ class CommentControllerTest {
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void editComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void editComment_returns200_whenHasPermission() throws Exception {

View File

@@ -12,8 +12,6 @@ import org.raddatz.familienarchiv.document.annotation.DocumentAnnotation;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.document.transcription.PersonMention;
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
@@ -32,7 +30,6 @@ class TranscriptionBlockMentionsRepositoryTest {
@Autowired TranscriptionBlockRepository blockRepository;
@Autowired DocumentRepository documentRepository;
@Autowired AnnotationRepository annotationRepository;
@Autowired PersonRepository personRepository;
@Autowired EntityManager em;
private UUID documentId;
@@ -58,9 +55,8 @@ class TranscriptionBlockMentionsRepositoryTest {
@Test
void mentionedPersons_roundTripsTwoEntries() {
// person_id is a real FK since V71 — the mentioned persons must exist.
UUID auguste = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId();
UUID hermann = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId();
UUID auguste = UUID.randomUUID();
UUID hermann = UUID.randomUUID();
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId)
@@ -101,9 +97,8 @@ class TranscriptionBlockMentionsRepositoryTest {
@Test
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
// person_id is a real FK since V71 — the mentioned persons must exist.
UUID augusteId = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId();
UUID hermannId = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId();
UUID augusteId = UUID.randomUUID();
UUID hermannId = UUID.randomUUID();
blockRepository.saveAndFlush(TranscriptionBlock.builder()
.annotationId(annotationId).documentId(documentId)

View File

@@ -1,18 +1,11 @@
package org.raddatz.familienarchiv.exception;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
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.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@@ -37,108 +30,4 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
}
}
@Test
void handleGeneric_incorrectResultSize_staysOpaque_noHibernateOrRowCountLeak() {
// #731: before the fix, a case-colliding alias/name made Hibernate throw
// NonUniqueResultException → IncorrectResultSizeDataAccessException, which has no
// dedicated handler and falls through to handleGeneric. The fix removes the throw, but
// this pins the handler: a stray one must stay opaque — no Hibernate class name, no SQL,
// no "2 results were returned" row count reaching the client (CWE-209).
IncorrectResultSizeDataAccessException ex = new IncorrectResultSizeDataAccessException(
"query did not return a unique result: 2 results were returned", 1, 2);
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleGeneric(ex);
assertThat(response.getStatusCode().value()).isEqualTo(500);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
assertThat(response.getBody().message())
.isEqualTo("An unexpected error occurred")
.doesNotContain("results were returned")
.doesNotContain("NonUnique")
.doesNotContain("IncorrectResultSize");
}
}
@Test
void handleDataIntegrityViolation_returns400_withoutLeakingConstraint_orSentry() {
// A DataIntegrityViolationException carries the constraint name + SQL in its message;
// the response and logs must never echo it (CWE-209). It must become a clean 400, not a 500.
DataIntegrityViolationException ex = new DataIntegrityViolationException(
"could not execute statement; constraint [chk_meta_date_end_after_start]; "
+ "column meta_date_end of relation documents");
Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
handlerLogger.addAppender(appender);
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response =
handler.handleDataIntegrityViolation(ex);
assertThat(response.getStatusCode().value()).isEqualTo(400);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(ErrorCode.VALIDATION_ERROR);
assertThat(response.getBody().message())
.doesNotContain("chk_")
.doesNotContain("meta_date");
// Defense-in-depth: an unanticipated integrity violation is not a system fault,
// so it must NOT fabricate a Sentry alert.
sentryMock.verifyNoInteractions();
} finally {
handlerLogger.detachAppender(appender);
}
assertThat(appender.list)
.as("logs a WARN line")
.anySatisfy(e -> assertThat(e.getLevel()).isEqualTo(Level.WARN));
assertThat(appender.list)
.as("never logs the SQL statement / values (would re-leak to Loki)")
.noneSatisfy(e -> {
assertThat(e.getFormattedMessage()).contains("could not execute statement");
});
}
@Test
void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() {
// Debuggability (DevOps): the WARN must name *which* constraint fired so an
// unanticipated violation isn't a silent mystery — but it must carry the name only,
// never the SQL statement or the offending values that the SQLException message holds.
java.sql.SQLException sql = new java.sql.SQLException(
"ERROR: violates check constraint; could not execute statement; values (1917-01-10)");
org.hibernate.exception.ConstraintViolationException cve =
new org.hibernate.exception.ConstraintViolationException(
"constraint violation", sql, "chk_meta_date_end_after_start");
DataIntegrityViolationException ex = new DataIntegrityViolationException("wrapper", cve);
Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
handlerLogger.addAppender(appender);
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response =
handler.handleDataIntegrityViolation(ex);
// Response stays generic and leak-free (CWE-209) regardless of what we log.
assertThat(response.getStatusCode().value()).isEqualTo(400);
assertThat(response.getBody().message())
.doesNotContain("chk_")
.doesNotContain("meta_date");
sentryMock.verifyNoInteractions();
} finally {
handlerLogger.detachAppender(appender);
}
assertThat(appender.list)
.as("WARN names the constraint for debuggability")
.anySatisfy(e -> assertThat(e.getFormattedMessage()).contains("chk_meta_date_end_after_start"));
assertThat(appender.list)
.as("but never the SQL statement or values")
.noneSatisfy(e -> assertThat(e.getFormattedMessage()).contains("could not execute statement"));
}
}

View File

@@ -1,66 +0,0 @@
package org.raddatz.familienarchiv.geschichte;
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.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Raw-SQL constraint tests for geschichten — deliberately NOT @Transactional at
* class level (see JourneyItemConstraintsTest for the rationale).
*
* The V75 CHECK is the atomic backstop for GeschichteService.MAX_INTRO_LENGTH on
* the verbatim JOURNEY intro write path. STORY bodies are intentionally exempt.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class GeschichteConstraintsTest {
@MockitoBean
S3Client s3Client;
@Autowired JdbcTemplate jdbcTemplate;
private UUID insertGeschichte(String type, String body) {
UUID id = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO geschichten (id, title, body, status, type, created_at, updated_at) "
+ "VALUES (?, ?, ?, 'DRAFT', ?, now(), now())",
id, "Constraints-Test", body, type);
return id;
}
@Test
void journey_intro_check_rejects_4001_chars() {
assertThatThrownBy(() -> insertGeschichte("JOURNEY", "x".repeat(4001)))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void journey_intro_check_accepts_exactly_4000_chars() {
UUID id = insertGeschichte("JOURNEY", "x".repeat(4000));
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
assertThat(count).isEqualTo(1);
}
@Test
void story_bodies_are_not_constrained_by_the_intro_check() {
UUID id = insertGeschichte("STORY", "<p>" + "x".repeat(4001) + "</p>");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
assertThat(count).isEqualTo(1);
}
}

View File

@@ -2,13 +2,15 @@ package org.raddatz.familienarchiv.geschichte;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.raddatz.familienarchiv.geschichte.GeschichteService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -19,25 +21,22 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(GeschichteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -48,9 +47,11 @@ class GeschichteControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean GeschichteService geschichteService;
@MockitoBean JourneyItemService journeyItemService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@MockitoBean
GeschichteService geschichteService;
@MockitoBean
CustomUserDetailsService customUserDetailsService;
// ─── GET /api/geschichten ────────────────────────────────────────────────
@@ -64,7 +65,7 @@ class GeschichteControllerTest {
@WithMockUser(authorities = "READ_ALL")
void list_returns200_forReader() throws Exception {
when(geschichteService.list(any(), any(), any(), anyInt()))
.thenReturn(List.of(summaryStub("Story A")));
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
mockMvc.perform(get("/api/geschichten"))
.andExpect(status().isOk())
@@ -100,50 +101,13 @@ class GeschichteControllerTest {
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesDocumentIdFilterToService() throws Exception {
UUID documentId = UUID.randomUUID();
when(geschichteService.list(any(), any(), eq(documentId), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("documentId", documentId.toString()))
.andExpect(status().isOk());
verify(geschichteService).list(any(), any(), eq(documentId), anyInt());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesLimitToService() throws Exception {
when(geschichteService.list(any(), any(), any(), eq(5)))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("limit", "5"))
.andExpect(status().isOk());
verify(geschichteService).list(any(), any(), any(), eq(5));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesStatusFilterToService() throws Exception {
when(geschichteService.list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("status", "PUBLISHED"))
.andExpect(status().isOk());
verify(geschichteService).list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt());
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void getById_returns200_whenFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
mockMvc.perform(get("/api/geschichten/{id}", id))
.andExpect(status().isOk())
@@ -155,7 +119,7 @@ class GeschichteControllerTest {
@WithMockUser(authorities = "READ_ALL")
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getView(id))
when(geschichteService.getById(id))
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
mockMvc.perform(get("/api/geschichten/{id}", id))
@@ -187,7 +151,7 @@ class GeschichteControllerTest {
void create_returns201_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
.thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
.thenReturn(draft(id, "New"));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("New");
@@ -215,7 +179,7 @@ class GeschichteControllerTest {
void update_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
.thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
.thenReturn(published(id, "Updated"));
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
@@ -244,202 +208,31 @@ class GeschichteControllerTest {
verify(geschichteService).delete(id);
}
// ─── POST /api/geschichten/{id}/items ────────────────────────────────────
@Test
void appendItem_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void appendItem_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void appendItem_returns201_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"Note\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(itemId.toString()))
.andExpect(jsonPath("$.position").value(10));
}
// ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
UUID.randomUUID(), UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenReturn(itemViewStub(itemId, 10, "Updated"));
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"Updated\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.note").value("Updated"));
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenReturn(itemViewStub(itemId, 10, null));
// Raw JSON — local objectMapper lacks JsonNullableModule
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\": null}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.note").value(nullValue()));
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_returns404_whenItemNotFound() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
}
// ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void deleteItem_returns204_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
.andExpect(status().isNoContent());
verify(journeyItemService).delete(id, itemId);
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void deleteItem_returns404_whenItemNotFound() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
.when(journeyItemService).delete(id, itemId);
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
}
// ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"itemIds\":[]}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void reorderItems_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"itemIds\":[\"" + itemId + "\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(itemId.toString()));
}
// ─── error mapping ───────────────────────────────────────────────────────
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void appendItem_returns409_on_position_conflict() throws Exception {
UUID id = UUID.randomUUID();
when(journeyItemService.append(eq(id), any()))
.thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
}
// ─── helpers ─────────────────────────────────────────────────────────────
private JourneyItemView itemViewStub(UUID id, int position, String note) {
return new JourneyItemView(id, position, null, note);
private Geschichte published(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.body("<p>x</p>")
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
}
private GeschichteView viewStub(UUID id, String title) {
return viewStub(id, title, GeschichteStatus.PUBLISHED);
}
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
return new GeschichteView(id, title, "<p>x</p>",
status, GeschichteType.STORY,
null, new HashSet<>(), List.of(),
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
}
/** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
private GeschichteSummary summaryStub(String title) {
return new GeschichteSummary() {
public UUID getId() { return UUID.randomUUID(); }
public String getTitle() { return title; }
public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; }
public GeschichteType getType() { return GeschichteType.STORY; }
public AuthorSummary getAuthor() { return null; }
public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
public LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
public String getBody() { return null; }
};
private Geschichte draft(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.status(GeschichteStatus.DRAFT)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
}
}

View File

@@ -1,298 +0,0 @@
package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.raddatz.familienarchiv.user.UserGroup;
import org.raddatz.familienarchiv.user.UserGroupRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.security.crypto.password.PasswordEncoder;
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 java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies Geschichte HTTP behaviour end-to-end at the real servlet layer.
*
* <p>No {@code @Transactional} at class level — that would keep a session open and
* mask LazyInitializationException caused by open-in-view: false. Each test seeds data
* directly via repositories and relies on the service's own transaction boundaries.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class GeschichteHttpTest {
@LocalServerPort int port;
@MockitoBean S3Client s3Client;
@Autowired GeschichteRepository geschichteRepository;
@Autowired AppUserRepository appUserRepository;
@Autowired UserGroupRepository userGroupRepository;
@Autowired PasswordEncoder passwordEncoder;
private RestTemplate http;
private String baseUrl;
private static final String WRITER_EMAIL = "geschichten-http-writer@test.de";
private static final String WRITER_PASSWORD = "pass!Geschichte1";
@BeforeEach
void setUp() {
http = noThrowRestTemplate();
baseUrl = "http://localhost:" + port;
geschichteRepository.deleteAll();
appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete);
appUserRepository.findByEmail(BLOG_WRITER_EMAIL).ifPresent(appUserRepository::delete);
userGroupRepository.findByName("HttpTest-BlogWriters").ifPresent(userGroupRepository::delete);
appUserRepository.save(AppUser.builder()
.email(WRITER_EMAIL)
.password(passwordEncoder.encode(WRITER_PASSWORD))
.build());
}
// ─── GET /api/geschichten ────────────────────────────────────────────────
@Test
void list_returns_200_and_empty_array_when_no_stories_exist() {
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten", HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isEqualTo("[]");
}
@Test
void list_returns_200_and_does_not_500_when_stories_have_journey_items() {
// Seed a JOURNEY directly — items are LAZY; without @Transactional(readOnly=true) +
// Hibernate.initialize in getById() this would 500. list() uses a projection so it
// must also never touch items.
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
Geschichte journey = Geschichte.builder()
.title("Reise durch die Briefe")
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(writer)
.publishedAt(LocalDateTime.now())
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
JourneyItem item = JourneyItem.builder()
.geschichte(journey)
.position(1000)
.note("Einleitung")
.build();
journey.getItems().add(item);
geschichteRepository.save(journey);
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten", HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).contains("Reise durch die Briefe");
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
@Test
void getById_returns_200_with_items_and_does_not_500_open_in_view_false() {
// This test is the canonical guard against LazyInitializationException.
// open-in-view: false means the Hibernate session is closed when Jackson serializes.
// GeschichteService.getById() must initialize items inside its @Transactional boundary.
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
Geschichte journey = Geschichte.builder()
.title("Familiengeschichte")
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(writer)
.publishedAt(LocalDateTime.now())
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
JourneyItem note = JourneyItem.builder()
.geschichte(journey).position(1000).note("Prolog").build();
JourneyItem note2 = JourneyItem.builder()
.geschichte(journey).position(2000).note("Epilog").build();
journey.getItems().add(note);
journey.getItems().add(note2);
Geschichte saved = geschichteRepository.save(journey);
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("Familiengeschichte")
.contains("Prolog")
.contains("Epilog");
}
@Test
void getById_returns_404_for_unknown_id() {
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + UUID.randomUUID(), HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(404);
assertThat(response.getBody()).contains("GESCHICHTE_NOT_FOUND");
}
@Test
void getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE() {
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
Geschichte draft = Geschichte.builder()
.title("Geheimer Entwurf")
.status(GeschichteStatus.DRAFT)
.author(writer)
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
Geschichte saved = geschichteRepository.save(draft);
// Writer lacks explicit BLOG_WRITE permission in the app_users table,
// so from the service's perspective they're a reader.
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(404);
}
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
@Test
void update_returns_200_and_serializes_items_open_in_view_false() {
// Canonical guard for the write path: PATCH must not 500 when the response
// is serialized after the service transaction closed. The raw entity carries
// a dead lazy items proxy at that point — the endpoint must answer with a
// view assembled inside the transaction.
AppUser writer = blogWriter();
Geschichte journey = Geschichte.builder()
.title("Reise vor dem Umbenennen")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.author(writer)
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
journey.getItems().add(JourneyItem.builder()
.geschichte(journey).position(1000).note("Prolog").build());
Geschichte saved = geschichteRepository.save(journey);
String session = loginAs(BLOG_WRITER_EMAIL, BLOG_WRITER_PASSWORD);
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.PATCH,
new HttpEntity<>("{\"title\":\"Reise nach dem Umbenennen\"}", csrfJsonHeaders(session)),
String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("Reise nach dem Umbenennen")
.contains("Prolog");
}
// ─── helpers ─────────────────────────────────────────────────────────────
private static final String BLOG_WRITER_EMAIL = "geschichten-http-blogwriter@test.de";
private static final String BLOG_WRITER_PASSWORD = "pass!Geschichte2";
/** A user whose group actually grants BLOG_WRITE — unlike the plain writer above. */
private AppUser blogWriter() {
UserGroup group = userGroupRepository.save(UserGroup.builder()
.name("HttpTest-BlogWriters")
.permissions(new HashSet<>(Set.of("BLOG_WRITE")))
.build());
return appUserRepository.save(AppUser.builder()
.email(BLOG_WRITER_EMAIL)
.password(passwordEncoder.encode(BLOG_WRITER_PASSWORD))
.groups(new HashSet<>(Set.of(group)))
.build());
}
/** Session cookie + double-submit CSRF pair + JSON content type for write requests. */
private HttpHeaders csrfJsonHeaders(String sessionId) {
String xsrf = UUID.randomUUID().toString();
HttpHeaders headers = new HttpHeaders();
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrf);
headers.set("X-XSRF-TOKEN", xsrf);
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
private String loginAsWriter() {
return loginAs(WRITER_EMAIL, WRITER_PASSWORD);
}
private String loginAs(String email, String password) {
String xsrf = UUID.randomUUID().toString();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Cookie", "XSRF-TOKEN=" + xsrf);
headers.set("X-XSRF-TOKEN", xsrf);
String body = "{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}";
ResponseEntity<String> resp = http.postForEntity(
baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class);
return extractFaSessionCookie(resp);
}
private HttpHeaders sessionHeaders(String sessionId) {
HttpHeaders headers = new HttpHeaders();
headers.set("Cookie", "fa_session=" + sessionId);
return headers;
}
private String extractFaSessionCookie(ResponseEntity<?> response) {
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
if (setCookieHeader == null) return "";
return setCookieHeader.stream()
.filter(c -> c.startsWith("fa_session="))
.map(c -> c.split(";")[0].substring("fa_session=".length()))
.findFirst()
.orElse("");
}
private RestTemplate noThrowRestTemplate() {
// JDK HttpClient factory — the default HttpURLConnection factory cannot send PATCH.
RestTemplate template = new RestTemplate(new JdkClientHttpRequestFactory());
template.setErrorHandler(new DefaultResponseErrorHandler() {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return false;
}
});
return template;
}
}

View File

@@ -1,262 +0,0 @@
package org.raddatz.familienarchiv.geschichte;
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.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemRepository;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class GeschichteListProjectionTest {
@Autowired GeschichteRepository geschichteRepository;
@Autowired AppUserRepository appUserRepository;
@Autowired PersonRepository personRepository;
@Autowired DocumentRepository documentRepository;
@Autowired JourneyItemRepository journeyItemRepository;
AppUser author;
AppUser otherAuthor;
@BeforeEach
void setUp() {
geschichteRepository.deleteAll();
author = appUserRepository.save(AppUser.builder()
.email("author@test").password("pw").build());
otherAuthor = appUserRepository.save(AppUser.builder()
.email("other@test").password("pw").build());
}
// ─── findSummaries returns only the requested status ─────────────────────
@Test
void findSummaries_returns_only_published_stories_when_effectiveStatus_is_PUBLISHED() {
geschichteRepository.save(published("Veröffentlicht", author));
geschichteRepository.save(draft("Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
}
@Test
void findSummaries_carries_updatedAt_for_dashboard_relative_times() {
// ReaderDraftsModule renders "bearbeitet vor X" from updatedAt — the
// projection must carry it for drafts, where publishedAt is null.
geschichteRepository.save(draft("Mein Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getUpdatedAt()).isNotNull();
}
@Test
void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
geschichteRepository.save(draft("Nur Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).isEmpty();
}
// ─── AuthorSummary nested projection ─────────────────────────────────────
@Test
void findSummaries_exposes_nested_author_names_but_never_email() {
AppUser richAuthor = appUserRepository.save(AppUser.builder()
.firstName("Franz").lastName("Raddatz")
.email("franz@raddatz.de").password("pw").build());
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(1);
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
assertThat(a.getFirstName()).isEqualTo("Franz");
assertThat(a.getLastName()).isEqualTo("Raddatz");
// Design rule (GeschichteView.AuthorView javadoc): author projections never
// expose email or group memberships to readers.
assertThat(GeschichteSummary.AuthorSummary.class.getMethods())
.extracting(java.lang.reflect.Method::getName)
.doesNotContain("getEmail");
}
// ─── GeschichteType is exposed ────────────────────────────────────────────
@Test
void findSummaries_exposes_type_field() {
Geschichte journey = Geschichte.builder()
.title("Eine Reise")
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(author)
.publishedAt(LocalDateTime.now())
.build();
geschichteRepository.save(journey);
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY);
}
// ─── authorId filter (own-drafts gate) ───────────────────────────────────
@Test
void findSummaries_with_authorId_returns_only_own_drafts() {
geschichteRepository.save(draft("Mein Entwurf", author));
geschichteRepository.save(draft("Fremder Entwurf", otherAuthor));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf");
}
// ─── personCount = 0 → no person filter ──────────────────────────────────
@Test
void findSummaries_with_personCount_zero_ignores_personIds_and_returns_all() {
geschichteRepository.save(published("A", author));
geschichteRepository.save(published("B", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
assertThat(result).hasSize(2);
}
// ─── personCount > 0 AND-semantics ───────────────────────────────────────
@Test
void findSummaries_with_one_personId_returns_only_linked_stories() {
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
Geschichte withFranz = published("Franz story", author);
withFranz.getPersons().add(franz);
geschichteRepository.save(withFranz);
Geschichte withAnna = published("Anna story", author);
withAnna.getPersons().add(anna);
geschichteRepository.save(withAnna);
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Franz story");
}
@Test
void findSummaries_with_two_personIds_uses_AND_semantics() {
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
Geschichte both = published("Both", author);
both.getPersons().add(franz);
both.getPersons().add(anna);
geschichteRepository.save(both);
Geschichte onlyFranz = published("Only Franz", author);
onlyFranz.getPersons().add(franz);
geschichteRepository.save(onlyFranz);
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Both");
}
// ─── documentId filter (JPQL EXISTS subquery) ────────────────────────────
@Test
void findSummaries_with_documentId_returns_journey_containing_that_document() {
Document doc = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
Geschichte withDoc = geschichteRepository.save(journey("Reise mit Dokument", author));
Geschichte withoutDoc = geschichteRepository.save(journey("Reise ohne Dokument", author));
journeyItemRepository.save(JourneyItem.builder()
.geschichte(withDoc).document(doc).position(1).build());
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, doc.getId());
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Reise mit Dokument");
assertThat(result).extracting(GeschichteSummary::getTitle).doesNotContain("Reise ohne Dokument");
}
@Test
void findSummaries_with_unknown_documentId_returns_empty() {
geschichteRepository.save(journey("Irgendeine Reise", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, UUID.randomUUID());
assertThat(result).isEmpty();
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte published(String title, AppUser writer) {
return Geschichte.builder()
.title(title)
.status(GeschichteStatus.PUBLISHED)
.author(writer)
.publishedAt(LocalDateTime.now())
.build();
}
private Geschichte draft(String title, AppUser writer) {
return Geschichte.builder()
.title(title)
.status(GeschichteStatus.DRAFT)
.author(writer)
.build();
}
private Geschichte journey(String title, AppUser writer) {
return Geschichte.builder()
.title(title)
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(writer)
.publishedAt(LocalDateTime.now())
.build();
}
/** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
private List<UUID> sentinel() {
return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));
}
}

View File

@@ -1,38 +0,0 @@
package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GeschichteQueryServiceTest {
@Mock
GeschichteRepository geschichteRepository;
@InjectMocks
GeschichteQueryService geschichteQueryService;
@Test
void existsById_returns_true_when_geschichte_exists() {
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(true);
assertThat(geschichteQueryService.existsById(id)).isTrue();
}
@Test
void existsById_returns_false_when_geschichte_does_not_exist() {
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(false);
assertThat(geschichteQueryService.existsById(id)).isFalse();
}
}

View File

@@ -8,12 +8,9 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.geschichte.GeschichteView;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.beans.factory.annotation.Autowired;
@@ -42,7 +39,6 @@ class GeschichteServiceIntegrationTest {
S3Client s3Client;
@Autowired GeschichteService geschichteService;
@Autowired JourneyItemService journeyItemService;
@Autowired GeschichteRepository geschichteRepository;
@Autowired PersonRepository personRepository;
@Autowired AppUserRepository appUserRepository;
@@ -80,11 +76,11 @@ class GeschichteServiceIntegrationTest {
+ "<script>alert('xss')</script>");
dto.setPersonIds(List.of(franz.getId()));
GeschichteView created = geschichteService.create(dto);
Geschichte created = geschichteService.create(dto);
assertThat(created.id()).isNotNull();
assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.body())
assertThat(created.getId()).isNotNull();
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.getBody())
.contains("<strong>jeden Sonntag</strong>")
.doesNotContain("<script>");
@@ -93,7 +89,7 @@ class GeschichteServiceIntegrationTest {
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
UUID draftId = created.id();
UUID draftId = created.getId();
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
.hasMessageContaining("not found");
@@ -101,17 +97,16 @@ class GeschichteServiceIntegrationTest {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
publishDto.setStatus(GeschichteStatus.PUBLISHED);
GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.publishedAt()).isNotNull();
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.getPublishedAt()).isNotNull();
// Reader can now see and fetch it
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
Geschichte fetched = geschichteService.getById(draftId);
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId());
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
// Delete as writer; join rows go with it
authenticateAs(writer, Permission.BLOG_WRITE);
@@ -142,17 +137,17 @@ class GeschichteServiceIntegrationTest {
// No filter → all three
assertThat(geschichteService.list(null, List.of(), null, 50))
.extracting(GeschichteSummary::getId)
.extracting(Geschichte::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// Single filter (Anna) → all three
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
.extracting(GeschichteSummary::getId)
.extracting(Geschichte::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
.extracting(GeschichteSummary::getId)
.extracting(Geschichte::getId)
.containsExactly(storyAB);
// AND: Bertha AND Carl → none (no story has both)
@@ -179,7 +174,7 @@ class GeschichteServiceIntegrationTest {
geschichteService.create(dto);
authenticateAs(writer2, Permission.BLOG_WRITE);
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
assertThat(result).isEmpty();
}
@@ -190,7 +185,7 @@ class GeschichteServiceIntegrationTest {
dto.setBody("<p>body</p>");
dto.setPersonIds(personIds);
dto.setStatus(GeschichteStatus.PUBLISHED);
return geschichteService.create(dto).id();
return geschichteService.create(dto).getId();
}
private void authenticateAs(AppUser user, Permission... permissions) {

View File

@@ -2,28 +2,31 @@ package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@@ -34,11 +37,7 @@ import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -46,13 +45,17 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GeschichteServiceTest {
@Mock GeschichteRepository geschichteRepository;
@Mock PersonService personService;
@Mock DocumentService documentService;
@Mock UserService userService;
@Mock JourneyItemService journeyItemService;
@Mock
GeschichteRepository geschichteRepository;
@Mock
PersonService personService;
@Mock
DocumentService documentService;
@Mock
UserService userService;
@InjectMocks GeschichteService geschichteService;
@InjectMocks
GeschichteService geschichteService;
AppUser writer;
AppUser reader;
@@ -93,8 +96,7 @@ class GeschichteServiceTest {
Geschichte result = geschichteService.getById(id);
assertThat(result.getId()).isEqualTo(id);
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(result).isSameAs(draft);
}
@Test
@@ -106,8 +108,7 @@ class GeschichteServiceTest {
Geschichte result = geschichteService.getById(id);
assertThat(result.getId()).isEqualTo(id);
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(result).isSameAs(published);
}
@Test
@@ -122,207 +123,83 @@ class GeschichteServiceTest {
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
// ─── getView ──────────────────────────────────────────────────────────────
@Test
void getView_returns_assembled_view_and_delegates_to_journeyItemService() {
authenticateAs(reader, Permission.READ_ALL);
UUID id = UUID.randomUUID();
Geschichte published = published(id);
JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note");
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
when(journeyItemService.getItems(id)).thenReturn(List.of(item));
GeschichteView view = geschichteService.getView(id);
assertThat(view.id()).isEqualTo(id);
assertThat(view.items()).containsExactly(item);
verify(journeyItemService).getItems(id);
}
@Test
void getView_throws_NOT_FOUND_when_id_unknown() {
authenticateAs(reader, Permission.READ_ALL);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> geschichteService.getView(id))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
@Test
void toView_author_displayName_uses_firstName_lastName() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("author@test")
.firstName("Hans").lastName("Raddatz").build());
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.author().displayName()).isEqualTo("Hans Raddatz");
}
@Test
void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("anon@test").build());
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.author().displayName()).isEqualTo("[Unbekannt]");
}
@Test
void toView_author_email_is_not_in_author_view() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("secret@test")
.firstName("Max").lastName("M").build());
GeschichteView result = geschichteService.toView(published, List.of());
// AuthorView exposes only id + displayName — no email field at all
assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class);
assertThat(result.author().displayName()).doesNotContain("secret@test");
}
@Test
void toView_persons_are_mapped_to_PersonView() {
UUID id = UUID.randomUUID();
UUID personId = UUID.randomUUID();
Geschichte published = published(id);
published.setPersons(new HashSet<>(List.of(
Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build()
)));
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.persons()).hasSize(1);
GeschichteView.PersonView pv = result.persons().iterator().next();
assertThat(pv.id()).isEqualTo(personId);
assertThat(pv.firstName()).isEqualTo("Franz");
assertThat(pv.lastName()).isEqualTo("Raddatz");
}
@Test
void toView_items_are_passed_through() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.items()).isEmpty();
}
// ─── list ─────────────────────────────────────────────────────────────────
@Test
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
geschichteService.list(null, List.of(), null, 50);
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
// Status pinning lives inside the Specification; we assert end-to-end behaviour
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
// through the spec-aware repository method.
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
assertThat(out).hasSize(2);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
authenticateAs(reader, Permission.READ_ALL);
UUID personId = UUID.randomUUID();
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, List.of(personId), null, 50);
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() {
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
authenticateAs(reader, Permission.READ_ALL);
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, List.of(a, b), null, 50);
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_passes_documentId_to_repository_as_journey_item_filter() {
void list_filters_by_documentId() {
authenticateAs(reader, Permission.READ_ALL);
UUID documentId = UUID.randomUUID();
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
geschichteService.list(null, List.of(), documentId, 50);
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_passes_nil_uuid_sentinel_to_repository_when_no_person_filter_given() {
// B2: when personIds is empty/null the service must pass a sentinel NIL UUID
// so the IN() predicate is skipped without producing invalid empty-IN() SQL.
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
geschichteService.list(null, List.of(), null, 50);
UUID nilUUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
verify(geschichteRepository).findSummaries(
any(), any(), org.mockito.ArgumentMatchers.argThat(ids -> ids.contains(nilUUID)), anyLong(), any());
}
@Test
void list_caps_limit_at_max_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of(mock(GeschichteSummary.class)));
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
assertThat(out).hasSizeLessThanOrEqualTo(200);
}
@Test
@DisplayName("security: null status for blog writer returns PUBLISHED, never leaks drafts")
void list_with_blog_writer_and_null_status_returns_PUBLISHED_not_all_drafts() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(), null, 50);
verify(geschichteRepository).findSummaries(
eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
}
@Test
@DisplayName("security: DRAFT status scopes to current user only")
void list_with_DRAFT_status_scopes_to_current_user_not_all_authors() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
verify(geschichteRepository).findSummaries(
eq(GeschichteStatus.DRAFT), eq(writer.getId()), any(), anyLong(), any());
}
// ─── create ──────────────────────────────────────────────────────────────
@Test
@@ -336,11 +213,11 @@ class GeschichteServiceTest {
dto.setTitle("My Story");
dto.setBody("<p>plain text</p>");
GeschichteView saved = geschichteService.create(dto);
Geschichte saved = geschichteService.create(dto);
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.publishedAt()).isNull();
assertThat(saved.author().id()).isEqualTo(writer.getId());
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull();
assertThat(saved.getAuthor()).isSameAs(writer);
}
@Test
@@ -354,9 +231,9 @@ class GeschichteServiceTest {
dto.setTitle("XSS attempt");
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
GeschichteView saved = geschichteService.create(dto);
Geschichte saved = geschichteService.create(dto);
assertThat(saved.body())
assertThat(saved.getBody())
.contains("<p>safe</p>")
.doesNotContain("<script>")
.doesNotContain("onerror")
@@ -375,9 +252,9 @@ class GeschichteServiceTest {
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
GeschichteView saved = geschichteService.create(dto);
Geschichte saved = geschichteService.create(dto);
assertThat(saved.body())
assertThat(saved.getBody())
.contains("<h2>Heading</h2>")
.contains("<strong>bold</strong>")
.contains("<em>italic</em>")
@@ -400,9 +277,28 @@ class GeschichteServiceTest {
dto.setTitle("Linked");
dto.setPersonIds(List.of(personId));
GeschichteView saved = geschichteService.create(dto);
Geschichte saved = geschichteService.create(dto);
assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
assertThat(saved.getPersons()).containsExactly(person);
}
@Test
void create_resolves_documentIds_via_DocumentService() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).build();
when(documentService.getDocumentById(docId)).thenReturn(doc);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Linked doc");
dto.setDocumentIds(List.of(docId));
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getDocuments()).containsExactly(doc);
}
@Test
@@ -419,202 +315,6 @@ class GeschichteServiceTest {
.isEqualTo(ErrorCode.VALIDATION_ERROR);
}
@Test
void create_preserves_JOURNEY_type_from_dto() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("My Journey");
dto.setType(GeschichteType.JOURNEY);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
}
@Test
void create_defaults_to_STORY_when_type_is_null() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("My Story");
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
}
@Test
void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
// The journey intro is plain text: JourneyReader renders it via Svelte text
// interpolation (never {@html}), so the OWASP sanitizer's entity encoding
// would corrupt real content ("Müller & Söhne" → "Müller &amp; Söhne") and
// re-encode cumulatively on every editor round-trip.
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("Müller & Söhne, Temperatur < 0");
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0");
}
@Test
void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.JOURNEY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("Temperatur < 0 & Schnee");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee");
}
@Test
void update_still_sanitizes_STORY_body() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
}
// ─── length caps ─────────────────────────────────────────────────────────
@Test
void create_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("x".repeat(256));
assertThatThrownBy(() -> geschichteService.create(dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
}
@Test
void create_accepts_title_of_exactly_255_chars() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("x".repeat(255));
assertThat(geschichteService.create(dto).title()).hasSize(255);
}
@Test
void update_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("x".repeat(256));
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
}
@Test
void create_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("x".repeat(4001));
assertThatThrownBy(() -> geschichteService.create(dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
}
@Test
void create_accepts_JOURNEY_intro_of_exactly_4000_chars() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("x".repeat(4000));
assertThat(geschichteService.create(dto).body()).hasSize(4000);
}
@Test
void update_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.JOURNEY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("x".repeat(4001));
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
}
@Test
void update_does_not_apply_the_intro_cap_to_STORY_bodies() {
// STORY bodies are sanitized Tiptap HTML and intentionally unbounded —
// the 4000-char cap exists for the verbatim JOURNEY intro path only.
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>" + "x".repeat(4001) + "</p>");
assertThat(geschichteService.update(id, dto).body()).contains("<p>");
}
// ─── update ──────────────────────────────────────────────────────────────
@Test
@@ -630,10 +330,10 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.PUBLISHED);
GeschichteView saved = geschichteService.update(id, dto);
Geschichte saved = geschichteService.update(id, dto);
assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(saved.publishedAt()).isNotNull();
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(saved.getPublishedAt()).isNotNull();
}
@Test
@@ -649,10 +349,10 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.DRAFT);
GeschichteView saved = geschichteService.update(id, dto);
Geschichte saved = geschichteService.update(id, dto);
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.publishedAt()).isNull();
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull();
}
@Test
@@ -666,46 +366,9 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>");
GeschichteView saved = geschichteService.update(id, dto);
Geschichte saved = geschichteService.update(id, dto);
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
}
@Test
void update_rejects_type_change_with_409_GESCHICHTE_TYPE_IMMUTABLE() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setType(GeschichteType.JOURNEY);
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE);
}
@Test
void update_accepts_dto_carrying_the_unchanged_type() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setType(GeschichteType.STORY);
dto.setTitle("Unverändert getypt");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
assertThat(saved.title()).isEqualTo("Unverändert getypt");
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
}
@Test
@@ -763,7 +426,7 @@ class GeschichteServiceTest {
.body("<p>body</p>")
.status(GeschichteStatus.DRAFT)
.persons(new HashSet<>())
.items(new ArrayList<>())
.documents(new HashSet<>())
.build();
}
@@ -775,7 +438,7 @@ class GeschichteServiceTest {
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now().minusHours(1))
.persons(new HashSet<>())
.items(new ArrayList<>())
.documents(new HashSet<>())
.build();
}
}

View File

@@ -1,165 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level.
* A DataIntegrityViolationException inside a class-level @Transactional marks the tx
* rollback-only and cascades into TransactionSystemException on teardown.
* Each test inserts via jdbcTemplate and uses explicit SQL teardown.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class JourneyItemConstraintsTest {
@MockitoBean
S3Client s3Client;
@Autowired JdbcTemplate jdbcTemplate;
@Autowired GeschichteRepository geschichteRepository;
@Autowired DocumentRepository documentRepository;
private UUID geschichteId;
private UUID documentId;
@BeforeEach
void seed() {
jdbcTemplate.execute("DELETE FROM journey_items");
Document doc = documentRepository.save(Document.builder()
.title("Constraints-Test-Doc")
.originalFilename("ct.pdf")
.status(DocumentStatus.UPLOADED)
.build());
documentId = doc.getId();
Geschichte g = geschichteRepository.save(Geschichte.builder()
.title("Constraints-Test-Journey")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
geschichteId = g.getId();
}
@Test
void unique_constraint_is_deferrable_initially_deferred() {
Boolean condeferrable = jdbcTemplate.queryForObject(
"SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
Boolean.class);
Boolean condeferred = jdbcTemplate.queryForObject(
"SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
Boolean.class);
assertThat(condeferrable).as("constraint must be deferrable").isTrue();
assertThat(condeferred).as("constraint must be initially deferred").isTrue();
}
@Test
void unique_index_rejects_duplicate_document_per_geschichte() {
// Atomic backstop for the service-level dedup pre-check (check-then-insert race).
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 20, documentId))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void unique_index_allows_same_document_in_different_journeys() {
Geschichte other = geschichteRepository.save(Geschichte.builder()
.title("Andere Lesereise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), other.getId(), 10, documentId);
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE document_id = ?", Integer.class, documentId);
assertThat(count).isEqualTo(2);
}
@Test
void unique_index_allows_multiple_note_only_items() {
// document_id IS NULL rows must not collide — the index is partial.
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "erste Notiz");
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 20, "zweite Notiz");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
assertThat(count).isEqualTo(2);
}
@Test
void note_length_check_rejects_2001_chars() {
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "x".repeat(2001)))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void note_length_check_accepts_exactly_2000_chars() {
// Pins the boundary at the DB layer too — a future <= vs < migration edit
// must fail here, not only in the mock-based service test.
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "x".repeat(2000));
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
assertThat(count).isEqualTo(1);
}
@Test
void position_check_rejects_nonpositive() {
UUID itemId = UUID.randomUUID();
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
itemId, geschichteId, 0, "test"))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void unique_constraint_rejects_duplicate_position_per_geschichte() {
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId))
.isInstanceOf(DataIntegrityViolationException.class);
}
}

View File

@@ -1,261 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class JourneyItemDocumentDeleteTest {
@MockitoBean
S3Client s3Client;
@MockitoBean
AuditService auditService;
@MockitoSpyBean
DocumentRepository documentRepository;
@PersistenceContext
EntityManager em;
@Autowired DocumentService documentService;
@Autowired JourneyItemRepository journeyItemRepository;
@Autowired GeschichteRepository geschichteRepository;
@Autowired DocumentRepository docRepo;
@Autowired AppUserRepository appUserRepository;
@Autowired JdbcTemplate jdbcTemplate;
Geschichte journey;
Document doc;
AppUser writer;
@BeforeEach
void seed() {
writer = appUserRepository.save(AppUser.builder()
.email("delete-test-writer@test")
.password("hash")
.build());
doc = docRepo.save(Document.builder()
.title("Testbrief")
.originalFilename("testbrief.pdf")
.status(DocumentStatus.UPLOADED)
.build());
journey = geschichteRepository.save(Geschichte.builder()
.title("Eine Lesereise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(writer.getEmail(), null,
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
}
@AfterEach
void cleanup() {
SecurityContextHolder.clearContext();
reset(documentRepository);
// Deletion order is FK-load-bearing: journey_items reference both documents
// and geschichten, so children must be removed before their parents.
journeyItemRepository.deleteAll();
docRepo.deleteAll();
geschichteRepository.deleteAll();
appUserRepository.deleteAll();
}
// ─── AC-1: headline ───────────────────────────────────────────────────────
@Test
void deleting_document_linked_via_note_less_item_deletes_item_not_500() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
assertThat(journeyItemRepository.findById(item.getId())).isEmpty();
assertThat(docRepo.findById(doc.getId())).isEmpty();
}
// ─── AC-2: note-carrying item survives as placeholder ─────────────────────
@Test
void deleting_document_preserves_note_carrying_item_as_placeholder() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).note("curator context").build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
JourneyItem surviving = journeyItemRepository.findById(item.getId()).orElseThrow();
assertThat(surviving.getDocumentId()).isNull();
assertThat(surviving.getNote()).isEqualTo("curator context");
}
// ─── AC-3: note-only item untouched ───────────────────────────────────────
@Test
void deleting_document_does_not_affect_note_only_item() {
JourneyItem noteOnly = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).note("Einleitung").build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
JourneyItem reloaded = journeyItemRepository.findById(noteOnly.getId()).orElseThrow();
assertThat(reloaded.getDocumentId()).isNull();
assertThat(reloaded.getNote()).isEqualTo("Einleitung");
}
// ─── AC-4: asymmetric multi-journey ───────────────────────────────────────
@Test
void deleting_document_applies_independently_per_referencing_item() {
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
.title("Zweite Reise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
JourneyItem noteLess = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
JourneyItem noteCarrying = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey2).position(10).document(doc).note("Begleittext").build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
assertThat(journeyItemRepository.findById(noteLess.getId())).isEmpty();
JourneyItem surviving = journeyItemRepository.findById(noteCarrying.getId()).orElseThrow();
assertThat(surviving.getDocumentId()).isNull();
assertThat(surviving.getNote()).isEqualTo("Begleittext");
}
// ─── AC-5: rollback guard ─────────────────────────────────────────────────
@Test
void listener_deletes_roll_back_when_document_delete_fails() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
doThrow(new RuntimeException("simulated failure"))
.when(documentRepository).deleteById(any());
assertThatThrownBy(() -> documentService.deleteDocument(doc.getId(), writer.getId()))
.isInstanceOf(RuntimeException.class);
em.clear();
assertThat(journeyItemRepository.findById(item.getId())).isPresent();
}
// ─── AC-6: empty-string note boundary ────────────────────────────────────
@Test
void empty_string_note_item_is_cascaded_whitespace_only_note_is_preserved() {
// uq_journey_items_geschichte_document prevents two items with the same
// (geschichte_id, document_id) in one journey — use two separate journeys.
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
.title("Zweite Reise für AC-6")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
UUID emptyNoteItemId = UUID.randomUUID();
UUID whitespaceNoteItemId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
emptyNoteItemId, journey.getId(), 10, doc.getId(), "");
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
whitespaceNoteItemId, journey2.getId(), 20, doc.getId(), " ");
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
assertThat(journeyItemRepository.findById(emptyNoteItemId)).isEmpty();
JourneyItem whitespaceItem = journeyItemRepository.findById(whitespaceNoteItemId).orElseThrow();
assertThat(whitespaceItem.getDocumentId()).isNull();
assertThat(whitespaceItem.getNote()).isEqualTo(" ");
}
// ─── Idempotency / no-collateral ──────────────────────────────────────────
@Test
void deleting_document_in_zero_journeys_returns_no_collateral() {
Document unlinked = docRepo.save(Document.builder()
.title("Unverknüpfter Brief")
.originalFilename("other.pdf")
.status(DocumentStatus.UPLOADED)
.build());
JourneyItem unrelated = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).note("unrelated note").build());
em.clear();
documentService.deleteDocument(unlinked.getId(), writer.getId());
em.clear();
assertThat(docRepo.findById(unlinked.getId())).isEmpty();
assertThat(journeyItemRepository.findById(unrelated.getId())).isPresent();
assertThat(journeyItemRepository.count()).isEqualTo(1);
}
// ─── AC-7: audit — DOCUMENT_DELETED emitted, JOURNEY_ITEM_REMOVED absent ─
@Test
void deleting_document_emits_document_audit_but_no_journey_item_audit() {
journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
verify(auditService).logAfterCommit(eq(AuditKind.DOCUMENT_DELETED), eq(writer.getId()), eq(doc.getId()), any());
verify(auditService, never()).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), any(), any(), any());
}
}

View File

@@ -1,418 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
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.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;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class JourneyItemIntegrationTest {
@MockitoBean
S3Client s3Client;
@MockitoBean
AuditService auditService;
@PersistenceContext
EntityManager em;
@Autowired GeschichteRepository geschichteRepository;
@Autowired JourneyItemRepository journeyItemRepository;
@Autowired JourneyItemService journeyItemService;
@Autowired DocumentService documentService;
@Autowired DocumentRepository documentRepository;
@Autowired AppUserRepository appUserRepository;
Geschichte journey;
Document doc;
AppUser writer;
@BeforeEach
void seed() {
writer = appUserRepository.save(AppUser.builder()
.email("journey-writer@test")
.password("hash")
.build());
doc = documentRepository.save(Document.builder()
.title("Testbrief")
.originalFilename("testbrief.pdf")
.status(DocumentStatus.UPLOADED)
.build());
journey = geschichteRepository.save(Geschichte.builder()
.title("Eine Lesereise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
em.flush();
em.clear();
}
@AfterEach
void clearSecurity() {
SecurityContextHolder.clearContext();
}
private void authenticateAs(AppUser user, Permission... permissions) {
var authorities = java.util.Arrays.stream(permissions)
.map(p -> new SimpleGrantedAuthority(p.name()))
.toList();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
}
// ─── @OrderBy ─────────────────────────────────────────────────────────────
@Test
void items_are_returned_in_position_order_regardless_of_insertion_order() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
// Distinct content per item — V74's partial unique index forbids the same
// document twice in one journey, and ordering doesn't depend on it.
JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).note("erstes").build();
JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).note("zweites").build();
managed.getItems().addAll(List.of(third, first, second));
geschichteRepository.save(managed);
em.flush();
em.clear();
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
List<Integer> positions = reloaded.getItems().stream().map(JourneyItem::getPosition).toList();
assertThat(positions).containsExactly(1000, 2000, 3000);
}
// ─── Cascade ALL + orphanRemoval ──────────────────────────────────────────
@Test
void deleting_geschichte_cascade_deletes_all_journey_items() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(1000).document(doc).build());
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(2000).note("context").build());
geschichteRepository.save(managed);
em.flush();
em.clear();
UUID geschichteId = journey.getId();
geschichteRepository.deleteById(geschichteId);
em.flush();
assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty();
}
@Test
void removing_item_from_items_list_triggers_orphan_removal() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem item = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
managed.getItems().add(item);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
reloaded.getItems().removeIf(i -> i.getId().equals(itemId));
geschichteRepository.save(reloaded);
em.flush();
assertThat(journeyItemRepository.findById(itemId)).isEmpty();
}
// ─── GeschichteType round-trip ────────────────────────────────────────────
@Test
void type_persists_as_JOURNEY_and_roundtrips() {
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
assertThat(reloaded.getType()).isEqualTo(GeschichteType.JOURNEY);
}
@Test
void type_defaults_to_STORY_for_new_geschichten() {
Geschichte story = geschichteRepository.save(Geschichte.builder()
.title("Erinnerung")
.status(GeschichteStatus.DRAFT)
.build());
em.flush();
em.clear();
Geschichte reloaded = geschichteRepository.findById(story.getId()).orElseThrow();
assertThat(reloaded.getType()).isEqualTo(GeschichteType.STORY);
}
// ─── Note-only item (document_id IS NULL) ─────────────────────────────────
@Test
void note_only_item_persists_without_document() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem note = JourneyItem.builder()
.geschichte(managed).position(1000).note("Eine kurze Einleitung.").build();
managed.getItems().add(note);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID noteId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
JourneyItem reloaded = journeyItemRepository.findById(noteId).orElseThrow();
assertThat(reloaded.getDocumentId()).isNull();
assertThat(reloaded.getNote()).isEqualTo("Eine kurze Einleitung.");
}
// ─── Document-backed item exposes documentId ──────────────────────────────
@Test
void document_backed_item_exposes_document_uuid_via_getDocumentId() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem item = JourneyItem.builder()
.geschichte(managed).position(1000).document(doc).build();
managed.getItems().add(item);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
JourneyItem reloaded = journeyItemRepository.findById(itemId).orElseThrow();
assertThat(reloaded.getDocumentId()).isEqualTo(doc.getId());
}
// ─── ON DELETE SET NULL ───────────────────────────────────────────────────
@Test
void deleting_document_sets_item_document_to_null_not_delete_item() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem item = JourneyItem.builder()
.geschichte(managed).position(1000).document(doc).note("still here").build();
managed.getItems().add(item);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
// Route through service so the DocumentDeletingEvent fires and the listener
// removes note-less items before ON DELETE SET NULL acts on note-carrying rows.
documentService.deleteDocument(doc.getId(), writer.getId());
em.flush();
em.clear();
JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow();
assertThat(surviving.getDocumentId()).isNull();
assertThat(surviving.getNote()).isEqualTo("still here");
}
// ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ─────────
@Test
void saving_item_with_neither_document_nor_note_violates_check_constraint() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem empty = JourneyItem.builder()
.geschichte(managed).position(1000).build();
assertThatThrownBy(() -> {
journeyItemRepository.save(empty);
journeyItemRepository.flush();
}).isInstanceOf(Exception.class);
}
// ─── JourneyItemService.append — end-to-end persistence ──────────────────
@Test
void append_persists_item_at_position_10() {
// Arrange: authenticate as a user with BLOG_WRITE
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("First stop");
// Act
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
em.flush();
em.clear();
// Assert: item exists in DB at position 10
assertThat(view.position()).isEqualTo(10);
assertThat(view.note()).isEqualTo("First stop");
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
assertThat(persisted).hasSize(1);
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
}
@Test
void append_document_persists_and_rejects_duplicate() {
// Covers the document branch of append, including the duplicate guard —
// the derived exists query must resolve document.id, which the transient
// getDocumentId() getter on JourneyItem shadows for Spring Data.
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
em.flush();
em.clear();
assertThat(view.document()).isNotNull();
assertThat(view.document().id()).isEqualTo(doc.getId());
JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO();
duplicate.setDocumentId(doc.getId());
assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate))
.hasFieldOrPropertyWithValue("code",
org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
}
// ─── STORY-type Geschichten hold journey items (#795) ────────────────────
@Test
void story_type_can_hold_journey_items_through_service() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
em.flush();
em.clear();
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
assertThat(items).hasSize(1);
assertThat(items.get(0).id()).isEqualTo(appended.id());
assertThat(items.get(0).document().id()).isEqualTo(doc.getId());
}
@Test
void v72_migrated_story_items_keep_position_order_and_are_removable() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
Document docB = documentRepository.save(Document.builder()
.title("Zweiter Brief").originalFilename("b.pdf").status(DocumentStatus.UPLOADED).build());
Document docC = documentRepository.save(Document.builder()
.title("Dritter Brief").originalFilename("c.pdf").status(DocumentStatus.UPLOADED).build());
// V72 inserted journey_items rows directly with position gaps — mirror that
// by writing through the repository instead of the service.
JourneyItem first = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(10).document(doc).build());
JourneyItem second = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(20).document(docB).build());
JourneyItem third = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(30).document(docC).build());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId()))
.extracting(JourneyItemView::position)
.containsExactly(10, 20, 30);
journeyItemService.delete(story.getId(), second.getId());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId()))
.extracting(JourneyItemView::id)
.containsExactly(first.getId(), third.getId());
}
@Test
void story_item_with_deleted_document_survives_and_remains_deletable() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
// The note keeps chk_journey_item_not_empty satisfied once ON DELETE
// SET NULL clears document_id — a note-less item would block the
// document delete at the DB instead.
dto.setNote("Begleittext");
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
em.flush();
em.clear();
// Route through service so the DocumentDeletingEvent fires (V72 cascade fix).
documentService.deleteDocument(doc.getId(), writer.getId());
em.flush();
em.clear();
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
assertThat(items).hasSize(1);
assertThat(items.get(0).document()).isNull();
journeyItemService.delete(story.getId(), appended.id());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId())).isEmpty();
}
private Geschichte savedStory() {
return geschichteRepository.save(Geschichte.builder()
.title("Eine Geschichte")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.STORY)
.build());
}
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
@Test
void reorder_swaps_positions_atomically() {
// Arrange: append two items (pos 10, pos 20)
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO();
dto1.setNote("Item one");
JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1);
JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO();
dto2.setNote("Item two");
JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2);
assertThat(item1View.position()).isEqualTo(10);
assertThat(item2View.position()).isEqualTo(20);
// Act: reorder with [item2, item1]
JourneyReorderDTO reorderDto = new JourneyReorderDTO();
reorderDto.setItemIds(List.of(item2View.id(), item1View.id()));
List<JourneyItemView> reordered = journeyItemService.reorder(journey.getId(), reorderDto);
em.flush();
em.clear();
// Assert: item2 is now at position 10, item1 is at position 20
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
assertThat(persisted).hasSize(2);
assertThat(persisted.get(0).getId()).isEqualTo(item2View.id());
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
assertThat(persisted.get(1).getId()).isEqualTo(item1View.id());
assertThat(persisted.get(1).getPosition()).isEqualTo(20);
}
}

View File

@@ -1,822 +0,0 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.postgresql.util.PSQLException;
import org.postgresql.util.PSQLState;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class JourneyItemServiceTest {
@Mock JourneyItemRepository journeyItemRepository;
@Mock GeschichteQueryService geschichteQueryService;
@Mock DocumentService documentService;
@Mock AuditService auditService;
@Mock UserService userService;
@InjectMocks JourneyItemService journeyItemService;
UUID geschichteId = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
UUID actorId = UUID.randomUUID();
@BeforeEach
void setupAuth() {
AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("test@test.de", null,
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
}
// ─── toSummary — name composition ────────────────────────────────────────
@Test
void toSummary_uses_linked_person_firstName_lastName() {
Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
Document doc = makeDoc(docId, sender, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
}
@Test
void toSummary_falls_back_to_senderText_when_no_person() {
Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isEqualTo("Familie Müller");
}
@Test
void toSummary_returns_null_senderName_when_neither_person_nor_text() {
Document doc = makeDoc(docId, null, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isNull();
}
@Test
void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
Document doc = makeDoc(docId, null, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.receiverCount()).isEqualTo(0);
assertThat(summary.receiverName()).isNull();
}
@Test
void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.receiverCount()).isEqualTo(2);
assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
}
@Test
void toSummary_datePrecision_SEASON_roundtrips() {
Document doc = makeDoc(docId, null, List.of(), null, null);
doc.setMetaDatePrecision(DatePrecision.SEASON);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
}
@Test
void toSummary_datePrecision_APPROX_roundtrips() {
Document doc = makeDoc(docId, null, List.of(), null, null);
doc.setMetaDatePrecision(DatePrecision.APPROX);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
}
// ─── append ──────────────────────────────────────────────────────────────
@Test
void append_to_empty_journey_starts_at_10() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(10);
}
@Test
void append_after_reorder_continues_from_max_position() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(50);
}
@Test
void append_returns400_when_neither_documentId_nor_note() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("documentId or note");
}
@Test
void append_returns400_when_note_trims_to_empty_and_no_document() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote(" \n ");
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void append_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
// 2000 is the spec'd limit (frontend maxlength + i18n message agree) — see #793.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("x".repeat(2001));
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
}
@Test
void append_accepts_note_of_exactly_2000_chars() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "x".repeat(2000));
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("x".repeat(2000));
assertThat(journeyItemService.append(geschichteId, dto).note()).hasSize(2000);
}
@Test
void append_returns404_when_documentId_does_not_exist() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(documentService.findSummaryByIdInternal(docId))
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
}
@Test
void append_returns409_when_100_items_exist() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
}
@Test
void append_returns409_when_document_already_in_journey() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_to_STORY_type_creates_journey_item() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(false);
Document doc = makeDoc(docId, null, List.of(), null, null);
when(documentService.findSummaryByIdInternal(docId)).thenReturn(doc);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
when(journeyItemRepository.saveAndFlush(any())).thenReturn(savedItemWithDoc(itemId, story, 10, doc, null));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(10);
assertThat(view.document().id()).isEqualTo(docId);
}
@Test
void append_to_STORY_type_respects_capacity_cap() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
}
@Test
void append_to_STORY_type_rejects_duplicate_document() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void cap_is_COUNT_based_not_MAX_position_based() {
// 99 rows with MAX(position)=2000 should still accept the 100th append
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
}
@Test
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
// Two concurrent appends can both pass the exists() pre-check; the partial
// unique index then rejects the second INSERT at flush. The service must
// translate that into the same friendly 409 as the pre-check.
// Uses PSQLException with SQLState 23505 — the real payload Postgres delivers.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
PSQLState.UNIQUE_VIOLATION);
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"could not execute statement", psqlEx));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_maps_psql_sqlstate_23505_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
// B1: the dedup check must use PSQLException.getSQLState() == "23505", not
// constraint-name string matching — constraint renames must not regress this.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
// Simulate a real Postgres unique-violation: PSQLException with SQLState 23505
// wrapped by Spring's DataIntegrityViolationException.
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
PSQLState.UNIQUE_VIOLATION);
org.springframework.dao.DataIntegrityViolationException dive =
new org.springframework.dao.DataIntegrityViolationException("could not execute statement", psqlEx);
when(journeyItemRepository.saveAndFlush(any())).thenThrow(dive);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() throws Exception {
// An FK violation (document deleted between load and flush) must NOT be
// translated into "already added" — only the dedup unique index (23505) earns that 409.
// FK violations arrive as PSQLException with SQLState 23503 (foreign_key_violation).
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
PSQLException psqlEx = new PSQLException("foreign key violation", PSQLState.FOREIGN_KEY_VIOLATION);
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"could not execute statement", psqlEx));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
}
@Test
void append_audits_JOURNEY_ITEM_ADDED() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
journeyItemService.append(geschichteId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any());
}
// ─── updateNote ───────────────────────────────────────────────────────────
@Test
void updateNote_absent_leaves_note_unchanged() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Original note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
// note is null by default — absent from JSON, no-op
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isEqualTo("Original note");
verify(journeyItemRepository, never()).save(any());
}
@Test
void updateNote_null_clears_note_when_document_is_present() {
Geschichte journey = journey(geschichteId);
Document doc = makeDoc(docId, null, List.of(), null, null);
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.empty());
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isNull();
}
@Test
void updateNote_string_sets_note() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, null);
item.setNote(null);
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("New note"));
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isEqualTo("New note");
}
@Test
void updateNote_null_returns400_when_item_has_no_document() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.empty());
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void updateNote_whitespace_only_including_newlines_stored_as_null() {
Geschichte journey = journey(geschichteId);
Document doc = makeDoc(docId, null, List.of(), null, null);
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("\n \n"));
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isNull();
}
@Test
void patch_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("x".repeat(2001)));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
}
@Test
void updateNote_auditsNoteUpdate() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, null);
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("New note"));
journeyItemService.updateNote(geschichteId, itemId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any());
}
@Test
void patch_returns404_when_item_belongs_to_different_journey() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("text"));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
}
// ─── delete ───────────────────────────────────────────────────────────────
@Test
void delete_returns404_when_item_already_deleted() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
}
@Test
void delete_no_audit_when_item_not_found() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
.isInstanceOf(DomainException.class);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
@Test
void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
journeyItemService.delete(geschichteId, itemId);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any());
}
// ─── reorder ─────────────────────────────────────────────────────────────
@Test
void reorder_unknownGeschichteId_throws404() {
UUID unknownId = UUID.randomUUID();
// geschichteQueryService is not lenient-stubbed for unknownId → returns false
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND));
}
@Test
void reorder_returns400_when_itemIds_contain_duplicates() {
UUID id1 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id1)); // duplicate
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void reorder_returns400_when_itemId_belongs_to_different_journey() {
UUID foreignId = UUID.randomUUID();
UUID localId = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(foreignId));
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void reorder_returns400_when_ids_have_extra_items() {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id2));
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void reorder_returns200_when_empty_on_empty_journey() {
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of());
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
List<JourneyItemView> result = journeyItemService.reorder(geschichteId, dto);
assertThat(result).isEmpty();
}
@Test
void reorder_returns400_when_empty_on_nonempty_journey() {
UUID id1 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void reorder_returns_items_in_new_order_starting_at_10() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 20, null, "A");
JourneyItem item2 = savedItem(id2, journey, 10, null, "B");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1, id2));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item2, item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id2)); // want id1 first
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(2);
assertThat(views.get(0).id()).isEqualTo(id1);
assertThat(views.get(0).position()).isEqualTo(10);
assertThat(views.get(1).id()).isEqualTo(id2);
assertThat(views.get(1).position()).isEqualTo(20);
}
@Test
void reorder_identical_order_returns200() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1));
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(1);
assertThat(views.get(0).position()).isEqualTo(10);
}
@Test
void reorder_of_grandfathered_over_cap_journey_succeeds() {
Geschichte journey = journey(geschichteId);
// 130-item journey — reorder with all 130 IDs must succeed despite > 100 cap
List<UUID> ids = new java.util.ArrayList<>();
List<JourneyItem> items = new java.util.ArrayList<>();
for (int i = 1; i <= 130; i++) {
UUID id = UUID.randomUUID();
ids.add(id);
items.add(savedItem(id, journey, i * 10, null, "item " + i));
}
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(new HashSet<>(ids));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(items);
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(ids);
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(130);
}
@Test
void reorder_audits_JOURNEY_ITEMS_REORDERED() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1));
journeyItemService.reorder(geschichteId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any());
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte journey(UUID id) {
return Geschichte.builder()
.id(id)
.title("Test Journey")
.type(GeschichteType.JOURNEY)
.status(GeschichteStatus.DRAFT)
.build();
}
private Geschichte story(UUID id) {
return Geschichte.builder()
.id(id)
.title("Test Story")
.type(GeschichteType.STORY)
.status(GeschichteStatus.DRAFT)
.build();
}
private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
return JourneyItem.builder()
.id(id)
.geschichte(g)
.position(position)
.document(null) // no document entity to avoid LAZY issues in unit tests
.note(note)
.build();
}
private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
JourneyItem item = JourneyItem.builder()
.id(id)
.geschichte(g)
.position(position)
.document(doc)
.note(note)
.build();
return item;
}
private Document makeDoc(UUID id, Person sender, List<Person> receivers, String senderText, String receiverText) {
Document doc = Document.builder()
.id(id)
.title("Test Doc")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.senderText(senderText)
.receiverText(receiverText)
.sender(sender)
.build();
doc.setReceivers(new HashSet<>(receivers));
return doc;
}
}

View File

@@ -11,7 +11,6 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
import org.raddatz.familienarchiv.person.Person;
@@ -60,10 +59,8 @@ class DocumentImporterTest {
// override this stub locally (load_skipsFile_whenMagicByteCheckThrowsIoException).
lenient().when(fileStreamOpener.open(any(File.class)))
.thenAnswer(inv -> new java.io.FileInputStream(inv.getArgument(0, File.class)));
// Real factory (pure, dependency-free) so the title-content assertions below exercise
// the shared composition rather than a stub — the #726 single source of truth.
importer = new DocumentImporter(documentService, new DocumentTitleFactory(), personService,
tagService, s3Client, thumbnailAsyncRunner, fileStreamOpener);
importer = new DocumentImporter(documentService, personService, tagService, s3Client,
thumbnailAsyncRunner, fileStreamOpener);
ReflectionTestUtils.setField(importer, "bucketName", "test-bucket");
}

View File

@@ -1,9 +1,10 @@
package org.raddatz.familienarchiv.document;
package org.raddatz.familienarchiv.importing;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.nio.file.Files;
import java.nio.file.Path;

View File

@@ -1,244 +0,0 @@
package org.raddatz.familienarchiv.person;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Verifies V76: persons.birth_year/death_year (integer) become
* birth_date/death_date (date) + *_date_precision columns, with backfill to
* YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality
* pre-check that aborts the migration on corrupt year data.
*
* <p>Runs Flyway programmatically (no Spring context): each test gets its own
* database so the staged migrate-to-V75 → seed → migrate-to-latest flow and
* the abort cases cannot interfere with each other.
*/
class PersonBirthDeathMigrationTest {
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
private static final AtomicInteger DB_COUNTER = new AtomicInteger();
private String dbUrl;
@BeforeAll
static void startContainer() {
POSTGRES.start();
}
@AfterAll
static void stopContainer() {
POSTGRES.stop();
}
@BeforeEach
void createFreshDatabase() throws SQLException {
String dbName = "mig_v76_" + DB_COUNTER.incrementAndGet();
try (Connection conn = DriverManager.getConnection(
baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword());
Statement stmt = conn.createStatement()) {
stmt.execute("CREATE DATABASE " + dbName);
}
dbUrl = baseUrl(dbName);
}
@Test
void precheck_abortsWhenBirthYearAfterDeathYear() throws SQLException {
migrateTo("75");
seedPerson("Corrupt", 1950, 1940);
assertThatThrownBy(this::migrateToLatest)
.hasMessageContaining("V76 aborted")
.hasMessageContaining("birth_year > death_year");
}
@Test
void precheck_abortsWhenYearZeroPresent() throws SQLException {
migrateTo("75");
seedPerson("Zero", 0, null);
assertThatThrownBy(this::migrateToLatest)
.hasMessageContaining("V76 aborted")
.hasMessageContaining("birth_year=0 or death_year=0");
}
@Test
void backfill_birthOnly_becomesYearPrecisionDate_deathStaysUnknown() throws SQLException {
migrateTo("75");
seedPerson("BirthOnly", 1901, null);
migrateToLatest();
LifeDates row = lifeDates("BirthOnly");
assertThat(row.birthDate()).hasToString("1901-01-01");
assertThat(row.birthPrecision()).isEqualTo("YEAR");
assertThat(row.deathDate()).isNull();
assertThat(row.deathPrecision()).isEqualTo("UNKNOWN");
}
@Test
void backfill_deathOnly_becomesYearPrecisionDate_birthStaysUnknown() throws SQLException {
migrateTo("75");
seedPerson("DeathOnly", null, 1944);
migrateToLatest();
LifeDates row = lifeDates("DeathOnly");
assertThat(row.birthDate()).isNull();
assertThat(row.birthPrecision()).isEqualTo("UNKNOWN");
assertThat(row.deathDate()).hasToString("1944-01-01");
assertThat(row.deathPrecision()).isEqualTo("YEAR");
}
@Test
void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException {
migrateTo("75");
seedPerson("NoDates", null, null);
migrateToLatest();
LifeDates row = lifeDates("NoDates");
assertThat(row.birthDate()).isNull();
assertThat(row.birthPrecision()).isEqualTo("UNKNOWN");
assertThat(row.deathDate()).isNull();
assertThat(row.deathPrecision()).isEqualTo("UNKNOWN");
}
@Test
void backfill_neverProducesBirthDateAfterDeathDate() throws SQLException {
migrateTo("75");
seedPerson("SameYear", 1901, 1901);
seedPerson("Ordered", 1899, 1972);
migrateToLatest();
assertThat(countWhere("birth_date IS NOT NULL AND death_date IS NOT NULL AND birth_date > death_date"))
.isZero();
}
@Test
void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException {
migrateTo("75");
seedPerson("Schema", 1901, 1944);
migrateToLatest();
assertThat(columnExists("birth_year")).isFalse();
assertThat(columnExists("death_year")).isFalse();
assertThat(columnExists("birth_date")).isTrue();
assertThat(columnExists("death_date")).isTrue();
for (String constraint : new String[]{
"chk_person_birth_before_death",
"chk_person_birth_date_precision_coherence",
"chk_person_birth_date_precision_values",
"chk_person_death_date_precision_coherence",
"chk_person_death_date_precision_values"}) {
assertThat(constraintExists(constraint)).as(constraint).isTrue();
}
}
// --- helpers ---
private static String baseUrl(String dbName) {
return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName;
}
private void migrateTo(String targetVersion) {
flywayBuilder().target(targetVersion).load().migrate();
}
private void migrateToLatest() {
flywayBuilder().load().migrate();
}
private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() {
return Flyway.configure()
.dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword())
.locations("classpath:db/migration")
.placeholders(Map.of("grafanaDbPassword", "test-only"));
}
private void seedPerson(String lastName, Integer birthYear, Integer deathYear) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO persons (id, last_name, person_type, family_member, provisional, birth_year, death_year) "
+ "VALUES (gen_random_uuid(), ?, 'PERSON', false, false, ?, ?)")) {
stmt.setString(1, lastName);
stmt.setObject(2, birthYear);
stmt.setObject(3, deathYear);
stmt.executeUpdate();
}
}
private record LifeDates(Object birthDate, String birthPrecision, Object deathDate, String deathPrecision) {}
private LifeDates lifeDates(String lastName) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT birth_date, birth_date_precision, death_date, death_date_precision "
+ "FROM persons WHERE last_name = ?")) {
stmt.setString(1, lastName);
try (ResultSet rs = stmt.executeQuery()) {
assertThat(rs.next()).as("person %s exists", lastName).isTrue();
return new LifeDates(
rs.getObject("birth_date"),
rs.getString("birth_date_precision"),
rs.getObject("death_date"),
rs.getString("death_date_precision"));
}
}
}
private long countWhere(String condition) throws SQLException {
try (Connection conn = connect();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM persons WHERE " + condition)) {
rs.next();
return rs.getLong(1);
}
}
private boolean columnExists(String columnName) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT COUNT(*) FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND table_name = 'persons' AND column_name = ?")) {
stmt.setString(1, columnName);
try (ResultSet rs = stmt.executeQuery()) {
rs.next();
return rs.getInt(1) > 0;
}
}
}
private boolean constraintExists(String constraintName) throws SQLException {
try (Connection conn = connect();
PreparedStatement stmt = conn.prepareStatement(
"SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) {
stmt.setString(1, constraintName);
try (ResultSet rs = stmt.executeQuery()) {
rs.next();
return rs.getInt(1) > 0;
}
}
}
private Connection connect() throws SQLException {
return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword());
}
}

View File

@@ -4,7 +4,6 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonNameAlias;
@@ -23,7 +22,6 @@ 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 java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
@@ -217,14 +215,6 @@ class PersonControllerTest {
public String getAlias() { return null; }
public Integer getBirthYear() { return null; }
public Integer getDeathYear() { return null; }
public java.time.LocalDate getBirthDate() { return null; }
public org.raddatz.familienarchiv.document.DatePrecision getBirthDatePrecision() {
return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
}
public java.time.LocalDate getDeathDate() { return null; }
public org.raddatz.familienarchiv.document.DatePrecision getDeathDatePrecision() {
return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
}
public String getNotes() { return null; }
public boolean isFamilyMember() { return false; }
public boolean isProvisional() { return false; }
@@ -582,53 +572,18 @@ class PersonControllerTest {
void createPerson_returns200_withAllSixFields() throws Exception {
UUID id = UUID.randomUUID();
Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz")
.alias("Oma Maria")
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
.deathDate(LocalDate.of(1975, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
.notes("Some notes").build();
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
mockMvc.perform(post("/api/persons").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
"\"alias\":\"Oma Maria\"," +
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"DAY\"," +
"\"deathDate\":\"1975-01-01\",\"deathDatePrecision\":\"YEAR\"," +
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Maria"))
.andExpect(jsonPath("$.alias").value("Oma Maria"))
.andExpect(jsonPath("$.birthDate").value("1901-03-14"))
.andExpect(jsonPath("$.birthDatePrecision").value("DAY"));
}
// ─── #773: malformed date payloads return structured 400s, not Jackson traces ──
// Jackson rejects unknown enum values by default. Verified 2026-06-12: the only
// DeserializationFeature hit in src/main is RestClientOcrClient's private ObjectMapper
// (OCR HTTP client) — the Spring MVC mapper has no READ_UNKNOWN_ENUM_VALUES_* override.
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400WithStructuredErrorCode_whenPrecisionEnumInvalid() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"INVALID_VALUE\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void updatePerson_returns400WithStructuredErrorCode_whenBirthDateNotADate() throws Exception {
UUID id = UUID.randomUUID();
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
"\"birthDate\":\"not-a-date\",\"birthDatePrecision\":\"DAY\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
.andExpect(jsonPath("$.birthYear").value(1901));
}
// ─── Phase 1.2: @Size constraints ─────────────────────────────────────────

View File

@@ -5,9 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.Optional;
import java.util.UUID;
@@ -99,120 +97,24 @@ class PersonImportUpsertTest {
assertThat(result.getNotes()).isEqualTo("Nichte von Herbert");
}
// ─── life dates (ADR-025 extension via preferHumanDate, #773) ─────────────
@Test
void upsertBySourceRef_preservesDayPrecisionDate_onReimportWithDifferentYear() {
// A human entered the exact birthday in-app; the spreadsheet only knows a year.
Person handDated = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1890, 3, 14)).birthDatePrecision(DatePrecision.DAY).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
void upsertBySourceRef_fillsBlankYears_butPreservesHumanEditedYears_onReimport() {
// Existing has a human-set birthYear and a blank deathYear.
Person existing = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram")
.lastName("Cram").birthYear(1890).deathYear(null).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1888)
.birthYear(1888).deathYear(1965)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 3, 14));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
}
@Test
void upsertBySourceRef_preservesMonthPrecisionDate_onReimport() {
Person handDated = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.deathDate(LocalDate.of(1944, 11, 1)).deathDatePrecision(DatePrecision.MONTH).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.deathYear(1945)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.MONTH);
}
@Test
void upsertBySourceRef_refreshesYearPrecisionDate_whenSpreadsheetYearChanges() {
// YEAR precision means "the importer's year" — a corrected spreadsheet year wins.
Person yearOnly = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1890, 1, 1)).birthDatePrecision(DatePrecision.YEAR).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(yearOnly));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1888)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1888, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void upsertBySourceRef_fillsEmptyDateAtYearPrecision_onReimport() {
Person noDates = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.deathYear(1965)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void upsertBySourceRef_keepsDatesEmpty_whenSpreadsheetHasNoYear() {
Person noDates = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isNull();
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void upsertBySourceRef_translatesYearToDate_onFirstImport() {
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1890).deathYear(1965)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getBirthYear()).isEqualTo(1890); // human value kept
assertThat(result.getDeathYear()).isEqualTo(1965); // blank filled from canonical
}
@Test
@@ -297,70 +199,4 @@ class PersonImportUpsertTest {
assertThat(result.getGeneration()).isEqualTo(3);
}
// ─── conflicting canonical life dates degrade instead of hitting the DB CHECK ──
// (chk_person_birth_before_death would abort the whole batch — REQ-IMP-001)
@Test
void upsertBySourceRef_dropsBothDates_whenCanonicalBirthAfterDeath_newPerson() {
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1950).deathYear(1949)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isNull();
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void upsertBySourceRef_keepsHandEnteredBirth_andDropsConflictingCanonicalDeath() {
// A human entered an exact birthday; the spreadsheet's death year lies before it.
// The hand-entered side must survive, the conflicting canonical refresh is dropped.
Person handDated = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1950, 6, 1)).birthDatePrecision(DatePrecision.DAY).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.deathYear(1949)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1950, 6, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void upsertBySourceRef_keepsExistingYearDates_whenCanonicalRefreshConflicts() {
Person existing = Person.builder()
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
.birthDate(LocalDate.of(1900, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.deathDate(LocalDate.of(1980, 1, 1)).deathDatePrecision(DatePrecision.YEAR).build();
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
.sourceRef("clara-cram").lastName("Cram")
.birthYear(1990).deathYear(1985)
.personType(PersonType.PERSON).provisional(false).build();
Person result = personService.upsertBySourceRef(cmd);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1980, 1, 1));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
}
}

View File

@@ -18,13 +18,9 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -124,60 +120,37 @@ class PersonRepositoryTest {
.containsExactly("Anna", "Clara");
}
// ─── findByAlias (exact) / findAllByAliasIgnoreCase (case-folding siblings) ───
// ─── findByAliasIgnoreCase ────────────────────────────────────────────────
@Test
void findByAlias_returnsExactCaseMatchOnly() {
void findByAliasIgnoreCase_returnsMatchingPerson() {
personRepository.save(Person.builder()
.firstName("Karl").lastName("Brandt").alias("Opa Karl").build());
assertThat(personRepository.findByAlias("Opa Karl")).isPresent();
assertThat(personRepository.findByAlias("opa karl")).isEmpty(); // exact-case: a folded form does NOT match
Optional<Person> found = personRepository.findByAliasIgnoreCase("opa karl");
assertThat(found).isPresent();
assertThat(found.get().getFirstName()).isEqualTo("Karl");
}
@Test
void findAllByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
assertThat(personRepository.findAllByAliasIgnoreCase("nobody")).isEmpty();
void findByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
Optional<Person> found = personRepository.findByAliasIgnoreCase("nobody");
assertThat(found).isEmpty();
}
@Test
void findAllByAliasIgnoreCase_foldsUmlautCase_inRealPostgres() {
// Proves Postgres LOWER() folds ü the same way for both rows — a plain-ASCII probe would
// stay green even if umlaut folding regressed. Both case-colliding aliases must match.
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
assertThat(personRepository.findAllByAliasIgnoreCase("MÜLLER")).hasSize(2);
}
// ─── findByFirstNameAndLastName (exact) / findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase ───
// ─── findByFirstNameIgnoreCaseAndLastNameIgnoreCase ───────────────────────
@Test
void findByFirstNameAndLastName_returnsExactCaseMatchOnly() {
void findByFirstNameIgnoreCaseAndLastNameIgnoreCase_returnsMatch() {
personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());
assertThat(personRepository.findByFirstNameAndLastName("Maria", "Raddatz")).isPresent();
assertThat(personRepository.findByFirstNameAndLastName("maria", "raddatz")).isEmpty(); // exact-case only
}
Optional<Person> found = personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(
"maria", "raddatz");
@Test
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_foldsUmlautCase_inRealPostgres() {
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("HANS", "MÜLLER"))
.hasSize(2);
}
@Test
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_nullFirstName_foldsToNoMatch() {
// Fail-closed: a last-name-only filename (null first name) must NOT widen to first_name IS
// NULL and pull in the institution/last-name-only row as a "sender". Proven on real
// Postgres because a mocked unit test cannot catch the IS NULL vs `= NULL` semantics.
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
.isEmpty();
assertThat(found).isPresent();
assertThat(found.get().getFirstName()).isEqualTo("Maria");
}
// ─── findCorrespondents ───────────────────────────────────────────────────
@@ -393,6 +366,30 @@ class PersonRepositoryTest {
assertThat(result).hasSize(1);
}
// ─── deleteReceiverReferences ─────────────────────────────────────────────
@Test
void deleteReceiverReferences_removesPersonFromAllDocumentReceivers() {
Person toDelete = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
Document doc1 = documentRepository.save(Document.builder()
.title("Brief 1").originalFilename("b1.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender).receivers(Set.of(toDelete)).build());
Document doc2 = documentRepository.save(Document.builder()
.title("Brief 2").originalFilename("b2.pdf")
.status(DocumentStatus.UPLOADED)
.sender(sender).receivers(Set.of(toDelete)).build());
personRepository.deleteReceiverReferences(toDelete.getId());
entityManager.flush();
entityManager.clear();
assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty();
}
// ─── searchByName with aliases ───────────────────────────────────────────
@Test
@@ -431,67 +428,6 @@ class PersonRepositoryTest {
assertThat(results).hasSize(1);
}
@Test
void searchByName_findsByAliasFirstName() {
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
List<Person> results = personRepository.searchByName("Wilhelmina");
assertThat(results).hasSize(1);
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
}
@Test
void searchByName_ordersByLastNameThenFirstName() {
personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
personRepository.save(Person.builder().firstName("Anna").lastName("Cram").build());
personRepository.save(Person.builder().firstName("Bernd").lastName("Cram").build());
List<Person> results = personRepository.searchByName("Cram");
assertThat(results).extracting(Person::getFirstName)
.containsExactly("Anna", "Bernd", "Clara");
}
// ─── resolveByName fetch→classify, end-to-end on real Postgres (#763 review) ───
// The classifier unit tests in PersonServiceTest stub searchByName, so they never prove the
// fetch query actually finds an alias-only match and feeds it into classification. These walk
// the whole searchByName → resolveByName path over the real Postgres slice, closing AC#4/#5.
@Test
void resolveByName_maidenAlias_classifiesAsDirect_endToEnd() {
PersonService personService = new PersonService(personRepository, aliasRepository);
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).lastName("Cram").type(PersonNameAliasType.MAIDEN_NAME).sortOrder(0).build());
// Detach so resolveByName re-fetches with its lazy nameAliases loaded from the DB —
// the fresh-session behaviour the @Transactional(readOnly=true) path has in production.
entityManager.flush();
entityManager.clear();
NameMatches matches = personService.resolveByName("Clara Cram");
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
}
@Test
void resolveByName_aliasFirstName_classifiesAsDirect_endToEnd() {
PersonService personService = new PersonService(personRepository, aliasRepository);
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
aliasRepository.save(PersonNameAlias.builder()
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
entityManager.flush();
entityManager.clear();
NameMatches matches = personService.resolveByName("Wilhelmina");
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
}
// ─── searchWithDocumentCount with aliases ────────────────────────────────
@Test
@@ -771,288 +707,4 @@ class PersonRepositoryTest {
assertThat(found).isPresent();
assertThat(found.get().getGeneration()).isNull();
}
// ─── #684: ON DELETE integrity enforced at the database layer ──────────────
// A raw deleteById (bypassing PersonService) must keep referential integrity:
// documents.sender_id → SET NULL, document_receivers.person_id → CASCADE, and the
// transcription_block_mentioned_persons soft reference → CASCADE. These run against
// real Postgres because the FK ON DELETE behaviour never fires on H2.
@Test
void deleteById_personSenderOfAReceiverOfB_nullsSender_dropsReceiverRow_bothDocumentsSurvive() {
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
Person bystander = personRepository.save(Person.builder().firstName("Bleibt").lastName("Hier").build());
Document sent = documentRepository.save(Document.builder()
.title("Gesendet").originalFilename("sent.pdf")
.status(DocumentStatus.UPLOADED).sender(target).build());
Document received = documentRepository.save(Document.builder()
.title("Empfangen").originalFilename("received.pdf")
.status(DocumentStatus.UPLOADED).sender(bystander)
.receivers(Set.of(target)).build());
entityManager.flush();
entityManager.clear();
personRepository.deleteById(target.getId());
entityManager.flush();
entityManager.clear();
assertThat(personRepository.findById(target.getId())).isEmpty();
Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
assertThat(reloadedSent.getSender()).isNull(); // AC-1: SET NULL
Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
assertThat(reloadedReceived.getReceivers())
.noneMatch(p -> p.getId().equals(target.getId())); // AC-2: CASCADE drops the join row
// Cascade-boundary guard (Nora, non-negotiable): the cascade stops at the join/reference
// layer — both documents themselves survive. Guards against a future migration turning
// documents.sender_id SET NULL into CASCADE and destroying historical letters.
assertThat(documentRepository.findById(sent.getId())).isPresent();
assertThat(documentRepository.findById(received.getId())).isPresent();
}
@Test
void deleteById_receiverWithCoReceiver_dropsOnlyDeletedPersonsJoinRow() {
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
Document doc = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED).sender(sender)
.receivers(Set.of(target, coReceiver)).build());
entityManager.flush();
entityManager.clear();
personRepository.deleteById(target.getId());
entityManager.flush();
entityManager.clear();
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
assertThat(reloaded.getReceivers()).extracting(Person::getId)
.containsExactly(coReceiver.getId()); // co-receiver untouched
}
@Test
void deleteById_personIsSenderAndReceiverOfSameDocument_documentSurvives_senderNull_receiverDropped() {
// AC-8: the trickier same-document interaction the cross-document cases don't exercise.
Person target = personRepository.save(Person.builder().firstName("Beides").lastName("Person").build());
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
Document doc = documentRepository.save(Document.builder()
.title("Selbstbrief").originalFilename("self.pdf")
.status(DocumentStatus.UPLOADED).sender(target)
.receivers(Set.of(target, coReceiver)).build());
entityManager.flush();
entityManager.clear();
personRepository.deleteById(target.getId());
entityManager.flush();
entityManager.clear();
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
assertThat(reloaded.getSender()).isNull();
assertThat(reloaded.getReceivers()).extracting(Person::getId)
.containsExactly(coReceiver.getId());
}
@Test
void deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives() {
// AC-3: the @-mention sidecar is a CASCADE soft reference, but the literal "@Name" lives
// in transcription_blocks.text and must stay visible as plain text after the person goes.
Person mentioned = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build());
Person survivor = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
Document doc = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED).build());
entityManager.flush();
UUID annotationId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
entityManager.createNativeQuery(
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) "
+ "VALUES (?1, ?2, 1, 0.1, 0.2, 0.3, 0.1, '#fff')")
.setParameter(1, annotationId).setParameter(2, doc.getId()).executeUpdate();
entityManager.createNativeQuery(
"INSERT INTO transcription_blocks (id, annotation_id, document_id, text) VALUES (?1, ?2, ?3, ?4)")
.setParameter(1, blockId).setParameter(2, annotationId).setParameter(3, doc.getId())
.setParameter(4, "Brief an @Auguste Raddatz und @Clara Cram").executeUpdate();
// Two mention rows on the same block: the deleted person and an innocent bystander.
entityManager.createNativeQuery(
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
+ "VALUES (?1, ?2, ?3)")
.setParameter(1, blockId).setParameter(2, mentioned.getId())
.setParameter(3, "Auguste Raddatz").executeUpdate();
entityManager.createNativeQuery(
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
+ "VALUES (?1, ?2, ?3)")
.setParameter(1, blockId).setParameter(2, survivor.getId())
.setParameter(3, "Clara Cram").executeUpdate();
entityManager.flush();
entityManager.clear();
personRepository.deleteById(mentioned.getId());
entityManager.flush();
entityManager.clear();
Number mentionRows = (Number) entityManager.createNativeQuery(
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
.setParameter(1, mentioned.getId()).getSingleResult();
assertThat(mentionRows.longValue()).isZero();
// The cascade is scoped to the deleted person — the bystander's mention row is untouched.
Number survivorRows = (Number) entityManager.createNativeQuery(
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
.setParameter(1, survivor.getId()).getSingleResult();
assertThat(survivorRows.longValue()).isEqualTo(1);
String text = (String) entityManager.createNativeQuery(
"SELECT text FROM transcription_blocks WHERE id = ?1")
.setParameter(1, blockId).getSingleResult();
assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
}
// ─── #773: PersonSummaryDTO year projection from birth_date/death_date ──────
@Test
void findAllWithDocumentCount_derivesYearsFromDates_nullSafe() {
personRepository.save(Person.builder()
.firstName("Maria").lastName("Datiert")
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
.build());
personRepository.save(Person.builder()
.firstName("Nora").lastName("Undatiert")
.build());
entityManager.flush();
List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();
PersonSummaryDTO dated = all.stream()
.filter(p -> "Datiert".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(dated.getBirthYear()).isEqualTo(1901);
assertThat(dated.getDeathYear()).isNull();
PersonSummaryDTO undated = all.stream()
.filter(p -> "Undatiert".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(undated.getBirthYear()).isNull();
assertThat(undated.getDeathYear()).isNull();
}
@Test
void searchWithDocumentCount_groupByPath_derivesYearsFromDates() {
personRepository.save(Person.builder()
.firstName("Herbert").lastName("Gruppiert")
.birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Gruppiert");
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthYear()).isEqualTo(1899);
assertThat(found.get(0).getDeathYear()).isEqualTo(1972);
}
@Test
void findByFilter_derivesYearsFromDates() {
personRepository.save(Person.builder()
.firstName("Filtriert").lastName("Person")
.birthDate(LocalDate.of(1920, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.findByFilter(
null, null, null, null, false, "Filtriert", 10, 0);
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthYear()).isEqualTo(1920);
assertThat(found.get(0).getDeathYear()).isNull();
}
// ─── #773 follow-up: full date + precision exposed on the summary projection ──
// (the mention dropdown renders precise life dates from the list endpoint)
@Test
void findAllWithDocumentCount_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Maria").lastName("Praezise")
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
.deathDate(LocalDate.of(1972, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
.build());
personRepository.save(Person.builder()
.firstName("Nora").lastName("Datenlos")
.build());
entityManager.flush();
List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();
PersonSummaryDTO dated = all.stream()
.filter(p -> "Praezise".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(dated.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
assertThat(dated.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(dated.getDeathDate()).isEqualTo(LocalDate.of(1972, 1, 1));
assertThat(dated.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
PersonSummaryDTO undated = all.stream()
.filter(p -> "Datenlos".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(undated.getBirthDate()).isNull();
assertThat(undated.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(undated.getDeathDate()).isNull();
assertThat(undated.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void searchWithDocumentCount_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Herbert").lastName("Suchbar")
.birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
.deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Suchbar");
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthDate()).isEqualTo(LocalDate.of(1899, 1, 1));
assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1972, 6, 12));
assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
}
@Test
void findTopByDocumentCount_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Top").lastName("Dokumentiert")
.birthDate(LocalDate.of(1910, 5, 1)).birthDatePrecision(DatePrecision.MONTH)
.build());
entityManager.flush();
List<PersonSummaryDTO> top = personRepository.findTopByDocumentCount(10);
PersonSummaryDTO found = top.stream()
.filter(p -> "Dokumentiert".equals(p.getLastName())).findFirst().orElseThrow();
assertThat(found.getBirthDate()).isEqualTo(LocalDate.of(1910, 5, 1));
assertThat(found.getBirthDatePrecision()).isEqualTo(DatePrecision.MONTH);
assertThat(found.getDeathDate()).isNull();
assertThat(found.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
}
@Test
void findByFilter_exposesDateAndPrecisionFields() {
personRepository.save(Person.builder()
.firstName("Gefiltert").lastName("Genau")
.deathDate(LocalDate.of(1944, 11, 2)).deathDatePrecision(DatePrecision.DAY)
.build());
entityManager.flush();
List<PersonSummaryDTO> found = personRepository.findByFilter(
null, null, null, null, false, "Gefiltert", 10, 0);
assertThat(found).hasSize(1);
assertThat(found.get(0).getBirthDate()).isNull();
assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 2));
assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
}
}

View File

@@ -4,7 +4,6 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonType;
@@ -17,13 +16,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import org.springframework.mock.web.MockMultipartFile;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -37,7 +33,6 @@ class PersonServiceIntegrationTest {
@Autowired PersonService personService;
@Autowired PersonRepository personRepository;
@Autowired DocumentRepository documentRepository;
@Autowired DocumentService documentService;
@PersistenceContext EntityManager entityManager;
@@ -80,93 +75,6 @@ class PersonServiceIntegrationTest {
assertThat(result.getLastName()).isEqualTo("Cram");
}
// ─── #731: case-colliding alias resolution against real Postgres ───────────
// The umlaut pair is mandatory — only the real DB proves Postgres LOWER() folds ü; a
// plain-ASCII test would stay green while umlaut aliases regressed.
@Test
void findOrCreateByAlias_resolvesUmlautAliasCollision_toLowestId_withoutThrow() {
Person muller = personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
Person mullerLower = personRepository.save(Person.builder().lastName("müller").alias("müller").build());
UUID expected = muller.getId().compareTo(mullerLower.getId()) <= 0 ? muller.getId() : mullerLower.getId();
// No exact-case "MÜLLER" row → falls through to the case-insensitive branch with two
// candidates and must pick the lowest id, never throwing NonUniqueResultException.
Person resolved = personService.findOrCreateByAlias("MÜLLER");
assertThat(resolved.getId()).isEqualTo(expected);
}
@Test
void findOrCreateByAlias_umlautAliasCollision_isDeterministicAcrossCalls() {
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
Person first = personService.findOrCreateByAlias("MÜLLER");
Person second = personService.findOrCreateByAlias("MÜLLER");
assertThat(second.getId()).isEqualTo(first.getId());
}
// ─── #731: filename-based sender resolution against real Postgres ──────────
@Test
void storeDocument_resolvesSender_whenFilenameNameIsUnique() throws Exception {
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
Document doc = uploadNamed("1965-03-12_Müller_Hans.pdf").document();
assertThat(doc.getSender()).isNotNull();
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
}
@Test
void storeDocument_resolvesSender_onSingleCaseInsensitiveMatch() throws Exception {
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
// Filename folds to "hans müller"; the only stored person is "Hans Müller".
Document doc = uploadNamed("1965-03-12_müller_hans.pdf").document();
assertThat(doc.getSender()).isNotNull();
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
}
@Test
void storeDocument_leavesSenderUnset_whenFilenameNameIsAmbiguous() throws Exception {
// Two persons collide case-insensitively; the filename casing ("HANS"/"MÜLLER") matches
// neither exactly → no exact-case winner → bail to null (never an arbitrary guess), no 500.
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
Document doc = uploadNamed("1965-03-12_MÜLLER_HANS.pdf").document();
assertThat(doc.getSender()).isNull();
}
@Test
void storeDocument_leavesSenderUnset_whenFilenameHasNoFirstName() throws Exception {
// A last-name-only filename never resolves to a sender (the parser yields no parsed name).
personRepository.save(Person.builder().lastName("Müller").build());
Document doc = uploadNamed("1965-03-12_Müller.pdf").document();
assertThat(doc.getSender()).isNull();
}
@Test
void findByName_nullFirstName_resolvesToEmpty_inRealPostgres() {
// Fail-closed against the real DB: a null first name must NOT widen to first_name IS NULL
// and pick up the last-name-only row.
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
assertThat(personService.findByName(null, "Müller")).isEmpty();
}
private DocumentService.StoreResult uploadNamed(String filename) throws Exception {
MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1, 2, 3});
return documentService.storeDocument(file, null);
}
// ─── #667: confirm round-trip + reader-default semantics ──────────────────
@Test
@@ -272,9 +180,9 @@ class PersonServiceIntegrationTest {
@Test
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
// A person referenced as BOTH a document sender and a document receiver must delete
// cleanly via the service path: deletePerson just calls deleteById, and V71's ON DELETE
// constraints null the sender_id FK and drop the receiver join row, so there is no FK
// orphan and the documents themselves survive.
// cleanly: deletePerson nulls the sender_id FK and removes the receiver join row first
// (reassignSenderToNull → deleteReceiverReferences → deleteById), so no FK orphan and
// the documents themselves survive.
Person target = personRepository.save(Person.builder()
.firstName("Weg").lastName("Person").provisional(true).build());
Person bystander = personRepository.save(Person.builder()
@@ -288,16 +196,16 @@ class PersonServiceIntegrationTest {
.status(DocumentStatus.UPLOADED).sender(bystander)
.receivers(new java.util.HashSet<>(Set.of(target))).build());
// Persist the fixture and detach everything so the delete operates on the database
// directly without the persistence context holding stale references.
// Persist the fixture and detach everything so the native @Modifying deletes operate on
// the database directly without the persistence context holding stale references that
// would re-flush a now-deleted person as a transient association.
entityManager.flush();
entityManager.clear();
personService.deletePerson(target.getId());
// The ON DELETE cascade fires beneath Hibernate — flush the delete and clear the L1
// cache so the asserting reads observe the post-delete database state, not stale
// managed entities still holding the dropped sender/receiver associations.
// Native @Modifying queries bypass the persistence context — clear it so the asserting
// reads observe the post-delete database state, not stale managed entities.
entityManager.flush();
entityManager.clear();
@@ -312,38 +220,4 @@ class PersonServiceIntegrationTest {
// The other person and the documents themselves survive the delete.
assertThat(personRepository.findById(bystander.getId())).isPresent();
}
@Test
void mergePersons_targetInheritsReferences_sourceJoinRowCascadeDrops_noFkError() {
// AC-7: merging a source who is sender of A and receiver of B into a target leaves the
// target as sender of A and receiver of B, drops the source's leftover receiver row via
// V71's ON DELETE CASCADE (no explicit delete, no FK error), and co-receivers are intact.
Person source = personRepository.save(Person.builder().firstName("Anna").lastName("Alt").build());
Person target = personRepository.save(Person.builder().firstName("Anna").lastName("Neu").build());
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
Document docA = documentRepository.save(Document.builder()
.title("Von Anna").originalFilename("a.pdf")
.status(DocumentStatus.UPLOADED).sender(source).build());
Document docB = documentRepository.save(Document.builder()
.title("An Anna").originalFilename("b.pdf")
.status(DocumentStatus.UPLOADED).sender(sender)
.receivers(new java.util.HashSet<>(Set.of(source, coReceiver))).build());
entityManager.flush();
entityManager.clear();
personService.mergePersons(source.getId(), target.getId());
entityManager.flush();
entityManager.clear();
assertThat(personRepository.findById(source.getId())).isEmpty();
Document reloadedA = documentRepository.findById(docA.getId()).orElseThrow();
assertThat(reloadedA.getSender().getId()).isEqualTo(target.getId());
Document reloadedB = documentRepository.findById(docB.getId()).orElseThrow();
assertThat(reloadedB.getReceivers()).extracting(Person::getId)
.containsExactlyInAnyOrder(target.getId(), coReceiver.getId());
}
}

View File

@@ -8,9 +8,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonNameAlias;
import org.raddatz.familienarchiv.person.PersonNameAliasType;
@@ -19,7 +17,6 @@ import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -30,7 +27,6 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -151,11 +147,9 @@ class PersonServiceTest {
personService.deletePerson(id);
// Integrity is enforced by V71's ON DELETE constraints — the service only checks
// existence then deletes; it no longer detaches sender/receiver references itself.
verify(personRepository).findById(id);
verify(personRepository).reassignSenderToNull(id);
verify(personRepository).deleteReceiverReferences(id);
verify(personRepository).deleteById(id);
verifyNoMoreInteractions(personRepository);
}
@Test
@@ -244,49 +238,27 @@ class PersonServiceTest {
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria");
dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1975, 11, 2)); dto.setDeathDatePrecision(DatePrecision.DAY);
dto.setNotes("Some notes");
dto.setBirthYear(1901); dto.setDeathYear(1975); dto.setNotes("Some notes");
Person result = personService.createPerson(dto);
assertThat(result.getFirstName()).isEqualTo("Maria");
assertThat(result.getLastName()).isEqualTo("Raddatz");
assertThat(result.getAlias()).isEqualTo("Oma Maria");
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1975, 11, 2));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getBirthYear()).isEqualTo(1901);
assertThat(result.getDeathYear()).isEqualTo(1975);
assertThat(result.getNotes()).isEqualTo("Some notes");
}
@Test
void createPerson_dto_rejectsDateWithUnknownPrecision() {
void createPerson_dto_yearValidationFires_whenBirthYearNegative() {
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test");
dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.UNKNOWN);
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setBirthYear(-1);
assertThatThrownBy(() -> personService.createPerson(dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> {
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
}
@Test
void createPerson_dto_treatsNullPrecisionWithNullDateAsUnknown() {
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.PERSON);
Person result = personService.createPerson(dto);
assertThat(result.getBirthDate()).isNull();
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
@@ -400,57 +372,14 @@ class PersonServiceTest {
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
@Test
void findOrCreateByAlias_returnsExactCaseMatch_overCaseInsensitiveSibling() {
String alias = "müller";
Person exact = Person.builder().id(UUID.randomUUID()).alias("müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
void findOrCreateByAlias_returnsExisting_whenAliasFound() {
String alias = "Walter de Gruyter";
Person existing = Person.builder().id(UUID.randomUUID()).alias(alias).build();
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.of(existing));
Person result = personService.findOrCreateByAlias(alias);
assertThat(result).isEqualTo(exact);
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
verify(personRepository, never()).save(any());
}
@Test
void findOrCreateByAlias_returnsExactCaseMatch_evenWhenMultipleSiblingsCollide() {
String alias = "Müller";
Person exact = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
Person result = personService.findOrCreateByAlias(alias);
assertThat(result).isEqualTo(exact);
// exact-case short-circuits — the case-insensitive siblings are never consulted.
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
}
@Test
void findOrCreateByAlias_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
String alias = "müller";
Person only = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(only));
Person result = personService.findOrCreateByAlias(alias);
assertThat(result).isEqualTo(only);
verify(personRepository, never()).save(any());
}
@Test
void findOrCreateByAlias_returnsLowestIdDeterministically_whenMultipleCaseInsensitiveMatches() {
String alias = "müller";
Person lower = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000001")).alias("Müller").build();
Person higher = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000002")).alias("müller").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(higher, lower)); // unordered
Person first = personService.findOrCreateByAlias(alias);
Person second = personService.findOrCreateByAlias(alias);
assertThat(first.getId()).isEqualTo(lower.getId()); // lowest id wins
assertThat(second.getId()).isEqualTo(first.getId()); // same result every call — never throws
assertThat(result).isEqualTo(existing);
verify(personRepository, never()).save(any());
}
@@ -458,8 +387,7 @@ class PersonServiceTest {
void findOrCreateByAlias_createsNew_whenAliasNotFound() {
String alias = "Clara Cram";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.save(any())).thenReturn(saved);
Person result = personService.findOrCreateByAlias(alias);
@@ -472,8 +400,7 @@ class PersonServiceTest {
void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() {
String alias = "Clara Cram geb. de Gruyter";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.save(any())).thenReturn(saved);
when(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0);
when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
@@ -495,8 +422,7 @@ class PersonServiceTest {
@Test
void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() {
String alias = "Arthur Collignon GmbH";
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> {
Person p = inv.getArgument(0);
p.setId(UUID.randomUUID());
@@ -513,8 +439,7 @@ class PersonServiceTest {
@Test
void findOrCreateByAlias_setsGroupType_withFullNameInLastName() {
String alias = "Geschwister de Gruyter";
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.save(any())).thenAnswer(inv -> {
Person p = inv.getArgument(0);
p.setId(UUID.randomUUID());
@@ -532,8 +457,7 @@ class PersonServiceTest {
void findOrCreateByAlias_noAlias_whenNoGeb() {
String alias = "Clara Cram";
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
when(personRepository.save(any())).thenReturn(saved);
personService.findOrCreateByAlias(alias);
@@ -545,54 +469,11 @@ class PersonServiceTest {
void findOrCreateByAlias_trimsInput() {
String alias = " Clara Cram ";
Person saved = Person.builder().id(UUID.randomUUID()).alias("Clara Cram").build();
when(personRepository.findByAlias("Clara Cram")).thenReturn(Optional.of(saved));
when(personRepository.findByAliasIgnoreCase("Clara Cram")).thenReturn(Optional.of(saved));
personService.findOrCreateByAlias(alias);
verify(personRepository).findByAlias("Clara Cram");
}
// ─── findByName (filename-based sender resolution) ────────────────────────
@Test
void findByName_returnsExactCaseMatch_overCaseInsensitiveSibling() {
Person exact = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personRepository.findByFirstNameAndLastName("Hans", "Müller")).thenReturn(Optional.of(exact));
assertThat(personService.findByName("Hans", "Müller")).contains(exact);
verify(personRepository, never()).findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(any(), any());
}
@Test
void findByName_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
Person only = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
.thenReturn(List.of(only));
assertThat(personService.findByName("hans", "müller")).contains(only);
}
@Test
void findByName_bailsToEmpty_whenTwoOrMoreCaseInsensitiveMatches() {
Person a = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
Person b = Person.builder().id(UUID.randomUUID()).firstName("hans").lastName("müller").build();
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
.thenReturn(List.of(a, b));
// Ambiguous sender → unset, never an arbitrary guess (provenance correctness over a
// confidently-wrong pre-fill). This is the deliberate divergence from the alias path.
assertThat(personService.findByName("hans", "müller")).isEmpty();
}
@Test
void findByName_returnsEmpty_whenFirstNameNullFoldsToNoMatch() {
when(personRepository.findByFirstNameAndLastName(null, "Müller")).thenReturn(Optional.empty());
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
.thenReturn(List.of());
assertThat(personService.findByName(null, "Müller")).isEmpty();
verify(personRepository).findByAliasIgnoreCase("Clara Cram");
}
// ─── updatePerson (notes) ────────────────────────────────────────────────
@@ -625,135 +506,114 @@ class PersonServiceTest {
assertThat(result.getNotes()).isNull();
}
// ─── updatePerson (birth/death dates) ────────────────────────────────────
// ─── updatePerson (birth/death years) ────────────────────────────────────
@Test
void updatePerson_persistsBirthAndDeathDateWithPrecision() {
void updatePerson_persistsBirthAndDeathYear() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
dto.setDeathDate(LocalDate.of(1965, 6, 12)); dto.setDeathDatePrecision(DatePrecision.DAY);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 6, 12));
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
assertThat(result.getBirthYear()).isEqualTo(1890);
assertThat(result.getDeathYear()).isEqualTo(1965);
}
@Test
void updatePerson_throwsBirthAfterDeath_whenBirthDateAfterDeathDate() {
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1970, 5, 1)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1950, 5, 1)); dto.setDeathDatePrecision(DatePrecision.DAY);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> {
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsBirthAfterDeath_onMixedPrecisionLateBirthday() {
// Known limitation (#773): DAY-precision birth late in the death's YEAR-precision year
// compares against the year's backfilled Jan 1st and is rejected. The error message
// carries the workaround hint via the BIRTH_AFTER_DEATH i18n key.
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1901, 11, 15)); dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setDeathDate(LocalDate.of(1901, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
}
@Test
void updatePerson_doesNotThrow_whenBirthDateSetButDeathDateNull() {
void updatePerson_doesNotThrow_whenBirthYearNonNullButDeathYearIsNull() {
// Covers A && B short-circuit: birthYear != null (true) but deathYear == null (false) → no throw
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(null);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
assertThat(result.getDeathDate()).isNull();
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
assertThat(result.getBirthYear()).isEqualTo(1890);
assertThat(result.getDeathYear()).isNull();
}
@Test
void updatePerson_allowsEqualBirthAndDeathDate() {
void updatePerson_allowsSameYear() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1900, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
dto.setDeathDate(LocalDate.of(1900, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900);
Person result = personService.updatePerson(id, dto);
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1900, 1, 1));
assertThat(result.getBirthYear()).isEqualTo(1900);
assertThat(result.getDeathYear()).isEqualTo(1900);
}
// ─── Date/precision coherence (V76 CHECK constraint mirror) ─────────────
// ─── Phase 1.3: Year range bounds (> 0) ──────────────────────────────────
@Test
void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionUnknown() {
void updatePerson_throwsBadRequest_whenBirthYearIsZero() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setDeathDate(LocalDate.of(1944, 11, 2)); dto.setDeathDatePrecision(DatePrecision.UNKNOWN);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(0);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> {
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
});
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionNull() {
void updatePerson_throwsBadRequest_whenBirthYearIsNegative() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDate(LocalDate.of(1901, 3, 14));
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(-5);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsInvalidDatePrecision_whenPrecisionSetWithoutDate() {
void updatePerson_throwsBadRequest_whenDeathYearIsZero() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt");
dto.setBirthDatePrecision(DatePrecision.DAY);
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(0);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
@Test
void updatePerson_throwsBadRequest_whenDeathYearIsNegative() {
UUID id = UUID.randomUUID();
PersonUpdateDTO dto = new PersonUpdateDTO();
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(-10);
assertThatThrownBy(() -> personService.updatePerson(id, dto))
.isInstanceOf(ResponseStatusException.class)
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
.isEqualTo(400);
}
// ─── findCorrespondents ──────────────────────────────────────────────────
@@ -840,14 +700,10 @@ class PersonServiceTest {
personService.mergePersons(sourceId, targetId);
verify(personRepository).findById(sourceId);
verify(personRepository).findById(targetId);
verify(personRepository).reassignSender(sourceId, targetId);
verify(personRepository).insertMissingReceiverReference(sourceId, targetId);
verify(personRepository).deleteReceiverReferences(sourceId);
verify(personRepository).deleteById(sourceId);
// The source's leftover receiver rows cascade-drop via V71's ON DELETE CASCADE on
// deleteById — merge no longer deletes them explicitly.
verifyNoMoreInteractions(personRepository);
}
// ─── getAliases ─────────────────────────────────────────────────────────
@@ -944,165 +800,4 @@ class PersonServiceTest {
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(403);
}
@Test
void findByDisplayNameContaining_delegatesToSearchByName() {
Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
when(personRepository.searchByName("Walter")).thenReturn(List.of(walter));
List<Person> result = personService.findByDisplayNameContaining("Walter");
assertThat(result).containsExactly(walter);
verify(personRepository).searchByName("Walter");
}
// ─── tokenize (name-match contract) ───────────────────────────────────────
@Test
void tokenize_hyphenatedName_splitsOnHyphen() {
assertThat(PersonService.tokenize("Anna-Maria")).containsExactly("anna", "maria");
}
@Test
void tokenize_apostropheName_splitsOnApostrophe() {
assertThat(PersonService.tokenize("D'Angelo")).containsExactly("d", "angelo");
}
@Test
void tokenize_umlautName_lowercasesToSingleToken() {
assertThat(PersonService.tokenize("Müller")).containsExactly("müller");
}
@Test
void tokenize_doubleSpace_dropsEmptyTokens() {
assertThat(PersonService.tokenize("Clara Cram")).containsExactly("clara", "cram");
}
@Test
void tokenize_allWhitespace_returnsEmpty() {
assertThat(PersonService.tokenize(" ")).isEmpty();
}
@Test
void tokenize_null_returnsEmpty() {
assertThat(PersonService.tokenize(null)).isEmpty();
}
// ─── resolveByName (direct / partial classification) ──────────────────────
@Test
void resolveByName_singleDirectMatch_classifiesAsDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_maidenAliasToken_classifiesAsDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Müller")
.nameAliases(List.of(PersonNameAlias.builder().lastName("Cram")
.type(PersonNameAliasType.MAIDEN_NAME).build()))
.build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_aliasFirstNameToken_isFetchedAndClassified() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram")
.nameAliases(List.of(PersonNameAlias.builder().firstName("Wilhelmina").lastName("de Gruyter")
.type(PersonNameAliasType.BIRTH).build()))
.build();
when(personRepository.searchByName("wilhelmina")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Wilhelmina");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_middleName_stillDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara Maria").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_reorderedTokens_stillDirect() {
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Cram Clara");
assertThat(result.direct()).containsExactly(clara);
}
@Test
void resolveByName_cramVsCramer_classifiesAsPartial() {
Person cramer = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(cramer));
when(personRepository.searchByName("cram")).thenReturn(List.of(cramer));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.partial()).containsExactly(cramer);
}
@Test
void resolveByName_emptyAfterTokenizing_returnsNoCandidates() {
NameMatches result = personService.resolveByName(" - ");
assertThat(result.direct()).isEmpty();
verify(personRepository, never()).searchByName(any());
}
@Test
void resolveByName_directSortsBeyondCap_stillReturnedAsDirect() {
List<Person> pool = new java.util.ArrayList<>();
for (int i = 0; i < 10; i++) {
pool.add(Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build());
}
Person direct = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
pool.add(direct);
when(personRepository.searchByName("clara")).thenReturn(pool);
when(personRepository.searchByName("cram")).thenReturn(pool);
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).containsExactly(direct);
}
@Test
void resolveByName_over8Tokens_issuesAtMost8Fetches() {
personService.resolveByName("a b c d e f g h i j");
verify(personRepository, org.mockito.Mockito.atMost(8)).searchByName(any());
}
@Test
void resolveByName_samePersonFromTwoTokens_appearsOnce() {
// Both token fetches return the same person id — fetchPool's putIfAbsent must dedup so the
// candidate is classified once, not twice.
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
NameMatches result = personService.resolveByName("Clara Cram");
assertThat(result.direct()).hasSize(1);
assertThat(result.partial()).isEmpty();
}
}

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