Compare commits
343 Commits
ffc14dd2ff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d11378c254 | ||
|
|
f64acbc697 | ||
|
|
75e48f2922 | ||
|
|
ad344db2bf | ||
|
|
3626cd1a6d | ||
|
|
fe4e2d97d0 | ||
|
|
e712477d2b | ||
|
|
4419c434a1 | ||
|
|
687353a819 | ||
|
|
e4e277219e | ||
|
|
a75c46351f | ||
|
|
65a34d48b4 | ||
|
|
0e7095fee6 | ||
|
|
adac1b1f99 | ||
|
|
29ada9b681 | ||
|
|
92a2feba1e | ||
|
|
ba7e8ca6f5 | ||
|
|
f408f60631 | ||
| 38a6d6b0fc | |||
| b33d0eb850 | |||
|
|
4bcf568ed4 | ||
|
|
ddb1ec4df8 | ||
| d650b6c066 | |||
|
|
e63eaadc33 | ||
|
|
d4a25e34d8 | ||
|
|
8e63867ad8 | ||
|
|
6b0a06e8b1 | ||
|
|
7c1eef710c | ||
|
|
03e22a2f26 | ||
|
|
6878419156 | ||
|
|
09b77e9b36 | ||
|
|
9d202b042b | ||
|
|
8429b1e9f8 | ||
|
|
6959651b36 | ||
|
|
0ef4f4f07c | ||
|
|
f1bb9d3a69 | ||
|
|
ca52145556 | ||
|
|
9a26bf75b0 | ||
|
|
9c616f9fb8 | ||
|
|
0fe0ae5235 | ||
|
|
2c909f49a8 | ||
|
|
87fd0f39bb | ||
|
|
7f3ad8ce89 | ||
|
|
aa1f6436cc | ||
|
|
b825076733 | ||
|
|
01df815bad | ||
|
|
dcd0e725a7 | ||
|
|
39ff63921d | ||
|
|
5a09cd4cb4 | ||
|
|
4e0ebc72c8 | ||
|
|
0f0d89702d | ||
|
|
fb41affd4c | ||
|
|
dc366ed403 | ||
|
|
64b7b2315d | ||
|
|
2a7e133717 | ||
|
|
5387bc9247 | ||
|
|
847874abb3 | ||
|
|
573bca4986 | ||
|
|
86690fdbb6 | ||
|
|
6cb1025881 | ||
|
|
fc557bd9ae | ||
|
|
e94414b81a | ||
|
|
7eee688ce9 | ||
|
|
8905135006 | ||
|
|
8bd8390891 | ||
|
|
ed98729f75 | ||
|
|
db87a64cc0 | ||
|
|
d7d6d0638c | ||
|
|
a2f37f85a6 | ||
|
|
f22a1a1cfa | ||
|
|
2a0863cf3e | ||
|
|
9e97687d0f | ||
|
|
b665e1132d | ||
|
|
87af9ab446 | ||
|
|
0058b297d8 | ||
|
|
230f23e37c | ||
|
|
e604967a3f | ||
|
|
169e1ad9de | ||
|
|
f2f42ed415 | ||
|
|
5945824b54 | ||
|
|
fa41394e66 | ||
|
|
fb00c7818e | ||
|
|
8ed65f8602 | ||
|
|
9e425c98a1 | ||
|
|
ddce268113 | ||
| 4a43962c98 | |||
|
|
9a9e1c4c40 | ||
|
|
62c8ce4cb2 | ||
|
|
4c620619d4 | ||
|
|
44baff9c9c | ||
|
|
4634da9865 | ||
|
|
79e4a3f9db | ||
|
|
70e8a6e6ad | ||
|
|
3af1095d13 | ||
|
|
8c835e957a | ||
|
|
fe8fcba7a7 | ||
|
|
e0c80ac193 | ||
|
|
005265b5a8 | ||
|
|
684c6e63de | ||
|
|
e27d52b9ee | ||
|
|
6f5497c7bf | ||
|
|
e0fac783e8 | ||
|
|
202ea85a58 | ||
|
|
7679596c70 | ||
|
|
3d5dcd1f18 | ||
|
|
52fca38f0f | ||
|
|
662a8f3e80 | ||
|
|
cbba95c3f8 | ||
|
|
3536ed884c | ||
|
|
5a939d9222 | ||
|
|
93e90424ab | ||
|
|
e8f3004c4f | ||
|
|
9637ebbca2 | ||
|
|
df10a42069 | ||
|
|
64120a30b5 | ||
|
|
25252fc709 | ||
|
|
1f379a161d | ||
|
|
c0d034c85d | ||
|
|
ca93cde06e | ||
|
|
7629e35897 | ||
|
|
cd741b9f57 | ||
|
|
ddf378aaac | ||
|
|
20cfe41f21 | ||
|
|
43601a3770 | ||
|
|
6603bc5333 | ||
|
|
6753d115f9 | ||
|
|
73dd6c80fa | ||
|
|
9ade36dd3b | ||
|
|
378da60ae8 | ||
|
|
6d267f2269 | ||
|
|
ff76a3784f | ||
|
|
534665459f | ||
|
|
fd792f6d78 | ||
|
|
bafbf609eb | ||
|
|
2710f2e233 | ||
|
|
80f6468d52 | ||
|
|
a58378e8f0 | ||
|
|
d000170f52 | ||
|
|
d1ed9c022f | ||
|
|
1e5e8e43e8 | ||
|
|
8c198f22be | ||
|
|
6fd05e08d8 | ||
|
|
ab469b744c | ||
|
|
f07527158c | ||
|
|
9f75de0350 | ||
|
|
8a9fbc6aef | ||
|
|
0336d07980 | ||
|
|
61256942e1 | ||
|
|
6aaf8ddb9e | ||
|
|
1b9707c6cd | ||
|
|
8353e71eed | ||
|
|
0693cfddd1 | ||
|
|
f656f7c1ff | ||
|
|
7316c51d4a | ||
|
|
cf457cb96f | ||
|
|
83e0afb466 | ||
|
|
12db7b3596 | ||
|
|
26b45f1c78 | ||
|
|
e6ce00035e | ||
|
|
b1f77bcfb6 | ||
|
|
4d1a5862d0 | ||
|
|
4e8a430dc3 | ||
|
|
e1d404609e | ||
|
|
b36addde22 | ||
|
|
456e019c3d | ||
|
|
d3bb08e7ff | ||
|
|
6703347468 | ||
|
|
1d55901388 | ||
|
|
0cd4882ef4 | ||
|
|
a85b22efcf | ||
|
|
7627589844 | ||
|
|
96a1afe09a | ||
|
|
c1b125bdb2 | ||
|
|
e4a9999f2f | ||
|
|
e48c794c12 | ||
|
|
add619d81d | ||
|
|
a46c3b416b | ||
|
|
7e8b90c8ee | ||
|
|
fc5c837d2c | ||
|
|
4f874bf4e9 | ||
|
|
28997fc391 | ||
|
|
003bc9b8cb | ||
|
|
485e13cfea | ||
|
|
439a386a37 | ||
|
|
23006a6562 | ||
|
|
c35f51d209 | ||
|
|
5297c70453 | ||
|
|
ad820955fd | ||
|
|
27b6d58632 | ||
|
|
4db2e97490 | ||
|
|
25b23843c9 | ||
|
|
ad067d2e0e | ||
|
|
29015ee864 | ||
|
|
b1b8505b93 | ||
|
|
abe860bec7 | ||
|
|
ec9d46da7a | ||
|
|
e562b3bbea | ||
|
|
e725910402 | ||
|
|
782a34e34b | ||
|
|
30f450b0d1 | ||
|
|
d4c0287e92 | ||
|
|
301cfc5c9e | ||
|
|
724c3881e4 | ||
|
|
fab2930ca8 | ||
|
|
d83707ec3b | ||
|
|
caea0d5633 | ||
|
|
2bf14aeab9 | ||
|
|
5b565d5271 | ||
|
|
df0f4879b8 | ||
|
|
98d081397e | ||
|
|
4e68b81bf7 | ||
|
|
985b31f71f | ||
|
|
3fb312b1c6 | ||
|
|
e2ec45f819 | ||
|
|
7d9526440a | ||
|
|
13bbfa7abd | ||
|
|
975223c972 | ||
|
|
403a043d51 | ||
|
|
e259908d6a | ||
|
|
7d37e610da | ||
|
|
9c1eb7608b | ||
|
|
9bba5e4a7a | ||
|
|
751a48b22c | ||
|
|
58a30a6e2e | ||
|
|
2430092e43 | ||
|
|
4a93543645 | ||
|
|
b453c13bae | ||
|
|
599c3977fb | ||
|
|
03e2615fa7 | ||
|
|
3db6a3bf8f | ||
|
|
0e06626eef | ||
|
|
a47564934d | ||
|
|
02fb16a0bd | ||
|
|
4757a174c9 | ||
|
|
75293c6aa8 | ||
|
|
4e9b13c0e4 | ||
|
|
ad27c1f757 | ||
|
|
0e30e5c570 | ||
|
|
a6a8552a48 | ||
|
|
b0d28c1e0b | ||
|
|
420c0e3e10 | ||
|
|
cb61e63b02 | ||
|
|
8eb321ccea | ||
|
|
e16b7402bd | ||
|
|
229c1b0539 | ||
|
|
f24c415b04 | ||
|
|
4c57a2262f | ||
|
|
b8e01f997d | ||
|
|
e8e57d2712 | ||
|
|
817835fd6a | ||
|
|
c361b3cd45 | ||
|
|
5c8034d298 | ||
|
|
8b1b070254 | ||
|
|
4ca1c967d2 | ||
|
|
24d9d975d1 | ||
|
|
8a1cc2d1f0 | ||
|
|
d5bf401085 | ||
|
|
4944918692 | ||
|
|
bf90427bfa | ||
|
|
50f554680c | ||
|
|
1dd162f1be | ||
|
|
ff7cfd4b1a | ||
|
|
88600d54cd | ||
|
|
654ac1478c | ||
|
|
3a4c2c6225 | ||
|
|
73f614bc3a | ||
|
|
6c5e5273bb | ||
|
|
a574d96351 | ||
|
|
246568301a | ||
|
|
aab4fe37ae | ||
|
|
4ebebe1e07 | ||
|
|
81224829a2 | ||
|
|
7cc2ddc6ad | ||
|
|
da3067150d | ||
|
|
10249c33be | ||
|
|
9c12f62345 | ||
|
|
e5784caa9d | ||
|
|
4583ee2c4d | ||
|
|
0a7b4fa265 | ||
|
|
a3858b6c80 | ||
|
|
9f5d7b8570 | ||
|
|
f6da95014e | ||
|
|
7a655ce6f4 | ||
|
|
3b594c0b0b | ||
|
|
2e44cab614 | ||
|
|
4c2f036de0 | ||
|
|
dcb57ffacd | ||
|
|
1c961619f1 | ||
|
|
2cc43c3c44 | ||
|
|
6c4d10d12f | ||
|
|
2cdb48f4a4 | ||
|
|
6be7413ba4 | ||
|
|
33aeefbb5b | ||
|
|
4bbdd33344 | ||
|
|
f4f853be8b | ||
|
|
44b5934fa7 | ||
|
|
78cc537f0e | ||
|
|
fc69758a92 | ||
|
|
f55efda0d2 | ||
|
|
77eddfc599 | ||
|
|
a76999c3d4 | ||
|
|
6d4aa8bd5c | ||
|
|
1fc74f8892 | ||
|
|
29ea27319a | ||
|
|
16f1fe7616 | ||
|
|
5ea47d4ec7 | ||
|
|
2f1538754e | ||
|
|
138bf446e4 | ||
|
|
944370dcfd | ||
|
|
5edefdd082 | ||
|
|
97274beba0 | ||
|
|
c3652f5b57 | ||
|
|
397fc3c7e4 | ||
|
|
5d8d85057d | ||
|
|
58254b492b | ||
|
|
8cc6031ef0 | ||
|
|
ecae789be2 | ||
|
|
95d35c20b2 | ||
|
|
11dc25ef31 | ||
|
|
b1309db8db | ||
|
|
01b902e885 | ||
|
|
20db3d0d8f | ||
|
|
0306023610 | ||
|
|
8f836dfefb | ||
|
|
b170085311 | ||
|
|
d5a7974f3a | ||
|
|
53660eadc9 | ||
|
|
f4b631e1bc | ||
|
|
c1dd6d299f | ||
|
|
a458d3508b | ||
|
|
bb2a89da58 | ||
|
|
578bebbd8b | ||
|
|
7e859252a3 | ||
|
|
ba053b3c23 | ||
|
|
80f5e0b147 | ||
|
|
11b70d814f | ||
|
|
1dffb430ac | ||
|
|
1e5a45a027 | ||
|
|
ccc37fe1bb | ||
|
|
289c3bbfb5 | ||
|
|
8d29bb10e2 | ||
|
|
396c87f8ab | ||
|
|
7a6c2e877f |
@@ -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.
|
Every alert needs: description, severity, likely cause, resolution steps, escalation path.
|
||||||
|
|
||||||
3. **Upgrading VPS tier before profiling**
|
3. **Upgrading hardware before profiling**
|
||||||
```
|
```
|
||||||
# "The app feels slow" → upgrade from CX32 to CX42
|
# "The app feels slow" → order more RAM / a faster CPU
|
||||||
# Actual cause: unindexed query scanning 100k rows
|
# Actual cause: unindexed query scanning 100k rows
|
||||||
```
|
```
|
||||||
Profile with Grafana dashboards first. Most perceived performance issues are application bugs, not resource constraints.
|
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
|
Prometheus + Loki + Alertmanager
|
||||||
```
|
```
|
||||||
|
|
||||||
### Monthly Cost: ~23 EUR
|
### Monthly Cost: ~6 EUR (excl. server)
|
||||||
CX32 VPS (4 vCPU, 8GB RAM): 17 EUR · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
|
Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM): see invoice · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
|
||||||
|
|
||||||
### Reference Documentation
|
### Reference Documentation
|
||||||
- Full CI workflow, Gitea vs GitHub differences: `docs/infrastructure/ci-gitea.md`
|
- Full CI workflow, Gitea vs GitHub differences: `docs/infrastructure/ci-gitea.md`
|
||||||
|
|||||||
19
.env.example
19
.env.example
@@ -72,6 +72,25 @@ VITE_SENTRY_DSN=
|
|||||||
# Sentry/GlitchTip auth token for source map upload at build time (optional)
|
# Sentry/GlitchTip auth token for source map upload at build time (optional)
|
||||||
SENTRY_AUTH_TOKEN=
|
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
|
# Production SMTP — uncomment and fill in to send real emails instead of catching them
|
||||||
# APP_BASE_URL=https://your-domain.example.com
|
# APP_BASE_URL=https://your-domain.example.com
|
||||||
# MAIL_HOST=smtp.example.com
|
# MAIL_HOST=smtp.example.com
|
||||||
|
|||||||
127
.gitea/actions/deploy-obs/action.yml
Normal file
127
.gitea/actions/deploy-obs/action.yml
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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"
|
||||||
41
.gitea/actions/reload-caddy/action.yml
Normal file
41
.gitea/actions/reload-caddy/action.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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'
|
||||||
58
.gitea/actions/smoke-test/action.yml
Normal file
58
.gitea/actions/smoke-test/action.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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"
|
||||||
@@ -108,6 +108,32 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
- name: Run unit and component tests with coverage
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ name: nightly
|
|||||||
# - host ports: backend 8081, frontend 3001
|
# - host ports: backend 8081, frontend 3001
|
||||||
# - profile: staging (starts mailpit instead of a real SMTP relay)
|
# - 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:
|
# Required Gitea secrets:
|
||||||
# STAGING_POSTGRES_PASSWORD
|
# STAGING_POSTGRES_PASSWORD
|
||||||
# STAGING_MINIO_PASSWORD
|
# STAGING_MINIO_PASSWORD
|
||||||
@@ -55,6 +60,8 @@ jobs:
|
|||||||
# for the same repo is within that boundary.
|
# for the same repo is within that boundary.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Write staging env file
|
- name: Write staging env file
|
||||||
@@ -92,6 +99,7 @@ jobs:
|
|||||||
# `compose config` renders both shorthand and longform mounts as
|
# `compose config` renders both shorthand and longform mounts as
|
||||||
# `target: /import` + `read_only: true`, so we assert against
|
# `target: /import` + `read_only: true`, so we assert against
|
||||||
# the rendered form rather than the raw source YAML.
|
# the rendered form rather than the raw source YAML.
|
||||||
|
# App-compose check (not obs), nightly-only — stays inline.
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
docker compose \
|
docker compose \
|
||||||
@@ -128,150 +136,21 @@ jobs:
|
|||||||
--profile staging \
|
--profile staging \
|
||||||
up -d --wait --remove-orphans
|
up -d --wait --remove-orphans
|
||||||
|
|
||||||
- name: Deploy observability configs
|
# POSTGRES_HOST is derived from the Compose project name (archiv-staging)
|
||||||
# Copies the compose file and config tree from the workspace checkout
|
# and service name (db). A project rename requires updating this value.
|
||||||
# into /opt/familienarchiv/ — the permanent location that persists
|
- uses: ./.gitea/actions/deploy-obs
|
||||||
# between CI runs. Containers started in the next step bind-mount
|
with:
|
||||||
# from there, so a future workspace wipe cannot corrupt a running
|
grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||||
# config file.
|
grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||||
#
|
glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||||
# obs-secrets.env is written fresh from Gitea secrets on every run so
|
postgres_password: ${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||||
# Gitea is always the single source of truth for secret rotation.
|
postgres_host: archiv-staging-db-1
|
||||||
# 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
|
|
||||||
|
|
||||||
- name: Validate observability compose config
|
- uses: ./.gitea/actions/reload-caddy
|
||||||
# Dry-run: resolves all variable substitutions and reports any missing
|
|
||||||
# required keys before containers start. Catches undefined variables and
|
|
||||||
# YAML errors in config files updated by the previous step.
|
|
||||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
|
||||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
|
||||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
|
||||||
run: |
|
|
||||||
docker compose \
|
|
||||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
|
||||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
|
||||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
|
||||||
config --quiet
|
|
||||||
|
|
||||||
- name: Start observability stack
|
- uses: ./.gitea/actions/smoke-test
|
||||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
with:
|
||||||
# that survive workspace wipes between nightly runs (see ADR-016).
|
host: staging.raddatz.cloud
|
||||||
# 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
|
- name: Cleanup env file
|
||||||
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ name: release
|
|||||||
# - host ports: backend 8080, frontend 3000
|
# - host ports: backend 8080, frontend 3000
|
||||||
# - profile: (none) — mailpit is excluded; real SMTP relay is used
|
# - 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:
|
# Required Gitea secrets:
|
||||||
# PROD_POSTGRES_PASSWORD
|
# PROD_POSTGRES_PASSWORD
|
||||||
# PROD_MINIO_PASSWORD
|
# PROD_MINIO_PASSWORD
|
||||||
@@ -53,6 +58,8 @@ jobs:
|
|||||||
# advertised label of our single-tenant self-hosted runner.
|
# advertised label of our single-tenant self-hosted runner.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Write production env file
|
- name: Write production env file
|
||||||
@@ -100,117 +107,21 @@ jobs:
|
|||||||
--env-file .env.production \
|
--env-file .env.production \
|
||||||
up -d --wait --remove-orphans
|
up -d --wait --remove-orphans
|
||||||
|
|
||||||
- name: Deploy observability configs
|
# POSTGRES_HOST is derived from the Compose project name (archiv-production)
|
||||||
# Mirrors the nightly approach: copies obs compose file and config tree
|
# and service name (db). A project rename requires updating this value.
|
||||||
# to /opt/familienarchiv/ (permanent path, survives workspace wipes — ADR-016),
|
- uses: ./.gitea/actions/deploy-obs
|
||||||
# then writes obs-secrets.env fresh from Gitea secrets.
|
with:
|
||||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||||
run: |
|
grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||||
rm -rf /opt/familienarchiv/infra/observability
|
glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||||
mkdir -p /opt/familienarchiv/infra/observability
|
postgres_password: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
postgres_host: archiv-production-db-1
|
||||||
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
|
|
||||||
|
|
||||||
- name: Validate observability compose config
|
- uses: ./.gitea/actions/reload-caddy
|
||||||
# Dry-run: resolves all variable substitutions and reports any missing
|
|
||||||
# required keys before containers start. Catches undefined variables and
|
|
||||||
# YAML errors in config files updated by the previous step.
|
|
||||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
|
||||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
|
||||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
|
||||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
|
||||||
run: |
|
|
||||||
docker compose \
|
|
||||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
|
||||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
|
||||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
|
||||||
config --quiet
|
|
||||||
|
|
||||||
- name: Start observability stack
|
- uses: ./.gitea/actions/smoke-test
|
||||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
with:
|
||||||
# that survive workspace wipes between runs (see ADR-016).
|
host: archiv.raddatz.cloud
|
||||||
# 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
|
- name: Cleanup env file
|
||||||
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
||||||
|
|||||||
26
CLAUDE.md
26
CLAUDE.md
@@ -86,7 +86,8 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||||
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
||||||
├── filestorage/ FileService (S3/MinIO)
|
├── filestorage/ FileService (S3/MinIO)
|
||||||
├── geschichte/ Geschichte (story) domain
|
├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||||
|
│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||||
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
|
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
|
||||||
├── notification/ Notification domain + SseEmitterRegistry
|
├── notification/ Notification domain + SseEmitterRegistry
|
||||||
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
||||||
@@ -105,13 +106,15 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
|
|
||||||
### Domain Model
|
### Domain Model
|
||||||
|
|
||||||
| Entity | Table | Key relationships |
|
| Entity | Table | Key relationships |
|
||||||
| ----------- | ------------- | ------------------------------------------------------------------------------------- |
|
| ------------- | --------------- | --------------------------------------------------------------------------------------- |
|
||||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
| `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` |
|
||||||
|
|
||||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||||
|
|
||||||
@@ -152,7 +155,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
|
|||||||
|
|
||||||
### DTOs
|
### DTOs
|
||||||
|
|
||||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response 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.
|
||||||
|
|
||||||
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
||||||
|
|
||||||
@@ -160,7 +163,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ 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).
|
**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).
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
@@ -194,7 +197,6 @@ frontend/src/routes/
|
|||||||
│ ├── [id]/edit/ Person edit form
|
│ ├── [id]/edit/ Person edit form
|
||||||
│ ├── new/ Create person form
|
│ ├── new/ Create person form
|
||||||
│ └── review/ Triage view — confirm/rename/merge/delete provisional persons
|
│ └── review/ Triage view — confirm/rename/merge/delete provisional persons
|
||||||
├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
|
|
||||||
├── aktivitaeten/ Unified activity feed (Chronik)
|
├── aktivitaeten/ Unified activity feed (Chronik)
|
||||||
├── geschichten/ Stories — list, [id], [id]/edit, new
|
├── geschichten/ Stories — list, [id], [id]/edit, new
|
||||||
├── stammbaum/ Family tree (Stammbaum)
|
├── stammbaum/ Family tree (Stammbaum)
|
||||||
@@ -269,7 +271,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ 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).
|
**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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ src/main/java/org/raddatz/familienarchiv/
|
|||||||
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||||
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
||||||
├── filestorage/ # FileService (S3/MinIO)
|
├── filestorage/ # FileService (S3/MinIO)
|
||||||
├── geschichte/ # Geschichte (story) domain
|
├── geschichte/ # Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||||
|
│ └── journeyitem/ # JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||||
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
|
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
|
||||||
├── notification/ # Notification domain + SseEmitterRegistry
|
├── notification/ # Notification domain + SseEmitterRegistry
|
||||||
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
||||||
|
|||||||
@@ -29,3 +29,17 @@ Authorization: Basic Gast_User gast
|
|||||||
#GET
|
#GET
|
||||||
GET http://localhost:8080/api/admin/tags
|
GET http://localhost:8080/api/admin/tags
|
||||||
Authorization: Basic admin admin123
|
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
|
||||||
@@ -41,6 +41,27 @@
|
|||||||
<type>pom</type>
|
<type>pom</type>
|
||||||
<scope>import</scope>
|
<scope>import</scope>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -137,6 +158,12 @@
|
|||||||
<artifactId>archunit-junit5</artifactId>
|
<artifactId>archunit-junit5</artifactId>
|
||||||
<version>1.3.0</version>
|
<version>1.3.0</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.wiremock</groupId>
|
||||||
|
<artifactId>wiremock-jetty12</artifactId>
|
||||||
|
<version>3.9.2</version>
|
||||||
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Excel Bearbeitung (Apache POI) -->
|
<!-- Excel Bearbeitung (Apache POI) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -50,10 +50,30 @@ public enum AuditKind {
|
|||||||
ADMIN_FORCE_LOGOUT,
|
ADMIN_FORCE_LOGOUT,
|
||||||
|
|
||||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||||
LOGIN_RATE_LIMITED;
|
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;
|
||||||
|
|
||||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
|
||||||
|
JOURNEY_ITEMS_REORDERED
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,13 @@ public class Document {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
||||||
|
|
||||||
|
// Not persisted — computed per detail fetch so read-only users can tell at first
|
||||||
|
// paint whether there is a transcription to read (DocumentService.getDocumentById).
|
||||||
|
@Transient
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean hasTranscription = false;
|
||||||
|
|
||||||
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
|
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
|
||||||
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
||||||
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.document;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -47,9 +46,7 @@ import org.raddatz.familienarchiv.document.DocumentService;
|
|||||||
import org.raddatz.familienarchiv.document.DocumentVersionService;
|
import org.raddatz.familienarchiv.document.DocumentVersionService;
|
||||||
import org.raddatz.familienarchiv.filestorage.FileService;
|
import org.raddatz.familienarchiv.filestorage.FileService;
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
import org.raddatz.familienarchiv.user.UserService;
|
||||||
import org.springframework.data.domain.Sort;
|
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.http.CacheControl;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -138,7 +135,7 @@ public class DocumentController {
|
|||||||
// --- METADATA ---
|
// --- METADATA ---
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Document getDocument(@PathVariable UUID id) {
|
public Document getDocument(@PathVariable UUID id) {
|
||||||
return documentService.getDocumentById(id);
|
return documentService.getDocumentDetail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@@ -171,8 +168,8 @@ public class DocumentController {
|
|||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
|
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id, Authentication authentication) {
|
||||||
documentService.deleteDocument(id);
|
documentService.deleteDocument(id, requireUserId(authentication));
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +313,8 @@ public class DocumentController {
|
|||||||
@RequestParam(required = false) Boolean undated,
|
@RequestParam(required = false) Boolean undated,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||||
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
|
SearchFilters filters = new SearchFilters(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
|
||||||
|
List<UUID> ids = documentService.findIdsForFilter(filters);
|
||||||
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
|
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
|
||||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
||||||
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
|
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
|
||||||
@@ -388,8 +386,9 @@ public class DocumentController {
|
|||||||
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
|
// 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.
|
// defaults to AND, which matches the frontend default and keeps old clients working.
|
||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
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);
|
Pageable pageable = PageRequest.of(page, size);
|
||||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, Boolean.TRUE.equals(undated), pageable));
|
return ResponseEntity.ok(documentService.searchDocuments(filters, sort, dir, pageable));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)
|
@GetMapping(value = "/density", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
@@ -404,9 +403,7 @@ public class DocumentController {
|
|||||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||||
DocumentDensityResult result = documentService.getDensity(
|
DocumentDensityResult result = documentService.getDensity(
|
||||||
new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
|
new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok(result);
|
||||||
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
|
|
||||||
.body(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- TRAINING LABELS ---
|
// --- TRAINING LABELS ---
|
||||||
@@ -445,17 +442,6 @@ public class DocumentController {
|
|||||||
return documentVersionService.getVersion(id, versionId);
|
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) {
|
private UUID requireUserId(Authentication authentication) {
|
||||||
return SecurityUtils.requireUserId(authentication, userService);
|
return SecurityUtils.requireUserId(authentication, userService);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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) {}
|
||||||
@@ -15,7 +15,6 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -37,6 +36,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@EntityGraph("Document.list")
|
@EntityGraph("Document.list")
|
||||||
Page<Document> findAll(Pageable pageable);
|
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
|
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
||||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||||
@@ -58,6 +64,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@EntityGraph("Document.full")
|
@EntityGraph("Document.full")
|
||||||
List<Document> findByReceiversId(UUID receiverId);
|
List<Document> findByReceiversId(UUID receiverId);
|
||||||
|
|
||||||
|
|
||||||
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
|
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
|
||||||
List<Document> findByTags_Id(UUID tagId);
|
List<Document> findByTags_Id(UUID tagId);
|
||||||
|
|
||||||
@@ -81,32 +88,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
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 = """
|
@Query(nativeQuery = true, value = """
|
||||||
SELECT d.id FROM documents d
|
SELECT d.id FROM documents d
|
||||||
CROSS JOIN LATERAL (
|
CROSS JOIN LATERAL (
|
||||||
|
|||||||
@@ -28,10 +28,13 @@ import org.raddatz.familienarchiv.ocr.TrainingLabel;
|
|||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.tag.Tag;
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
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.springframework.data.jpa.domain.Specification;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
@@ -68,6 +71,7 @@ import static org.raddatz.familienarchiv.document.DocumentSpecifications.*;
|
|||||||
public class DocumentService {
|
public class DocumentService {
|
||||||
|
|
||||||
private final DocumentRepository documentRepository;
|
private final DocumentRepository documentRepository;
|
||||||
|
private final DocumentTitleFactory documentTitleFactory;
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
@@ -77,6 +81,7 @@ public class DocumentService {
|
|||||||
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||||
private final AuditLogQueryService auditLogQueryService;
|
private final AuditLogQueryService auditLogQueryService;
|
||||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
public record StoreResult(Document document, boolean isNew) {}
|
public record StoreResult(Document document, boolean isNew) {}
|
||||||
|
|
||||||
@@ -137,8 +142,10 @@ public class DocumentService {
|
|||||||
* <p>Implementation note: groups in memory rather than via SQL GROUP BY
|
* <p>Implementation note: groups in memory rather than via SQL GROUP BY
|
||||||
* because the existing {@link Specification} predicates compose easily
|
* because the existing {@link Specification} predicates compose easily
|
||||||
* with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
|
* with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
|
||||||
* well under the 200ms p95 target. Cache-Control: max-age=300 on the
|
* well under the 200ms p95 target. The controller sets no explicit
|
||||||
* controller layer absorbs repeated browse loads.
|
* 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.
|
||||||
*
|
*
|
||||||
* <p>Tracked in issue #481 for re-evaluation when {@code documents > 50k}
|
* <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,
|
* — at that scale move the aggregation into SQL (GROUP BY TO_CHAR(meta_date,
|
||||||
@@ -167,11 +174,13 @@ public class DocumentService {
|
|||||||
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
||||||
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
|
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
|
||||||
boolean hasFts = ftsIds != null;
|
boolean hasFts = ftsIds != null;
|
||||||
Specification<Document> spec = buildSearchSpec(
|
// Density and search keep separate filter records (DensityFilters has no
|
||||||
hasFts, ftsIds, null, null,
|
// date/undated fields); adapt to SearchFilters here to reuse buildSearchSpec.
|
||||||
filters.sender(), filters.receiver(),
|
// Date bounds stay null and undated=false — the density path never filters by date.
|
||||||
filters.tags(), filters.tagQ(),
|
SearchFilters searchFilters = new SearchFilters(
|
||||||
filters.status(), filters.tagOperator(), false);
|
filters.text(), null, null, filters.sender(), filters.receiver(),
|
||||||
|
filters.tags(), filters.tagQ(), filters.status(), filters.tagOperator(), false);
|
||||||
|
Specification<Document> spec = buildSearchSpec(hasFts, ftsIds, searchFilters);
|
||||||
return documentRepository.findAll(spec).stream()
|
return documentRepository.findAll(spec).stream()
|
||||||
.map(Document::getDocumentDate)
|
.map(Document::getDocumentDate)
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
@@ -375,10 +384,17 @@ public class DocumentService {
|
|||||||
|
|
||||||
DocumentStatus statusBefore = doc.getStatus();
|
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
|
// 1. Einfache Felder Update
|
||||||
doc.setTitle(dto.getTitle());
|
doc.setTitle(resolveTitle(dto.getTitle(), autoTitleBefore, doc, dto));
|
||||||
doc.setDocumentDate(dto.getDocumentDate());
|
doc.setDocumentDate(dto.getDocumentDate());
|
||||||
applyDatePrecision(doc, dto);
|
applyDatePrecision(doc, dto);
|
||||||
|
validateDateRange(doc); // guard before any save (updateDocumentTags below persists)
|
||||||
doc.setLocation(dto.getLocation());
|
doc.setLocation(dto.getLocation());
|
||||||
doc.setTranscription(dto.getTranscription());
|
doc.setTranscription(dto.getTranscription());
|
||||||
doc.setSummary(dto.getSummary());
|
doc.setSummary(dto.getSummary());
|
||||||
@@ -419,7 +435,11 @@ public class DocumentService {
|
|||||||
doc.setScriptType(dto.getScriptType());
|
doc.setScriptType(dto.getScriptType());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
// 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.
|
||||||
boolean fileReplaced = newFile != null && !newFile.isEmpty();
|
boolean fileReplaced = newFile != null && !newFile.isEmpty();
|
||||||
if (fileReplaced) {
|
if (fileReplaced) {
|
||||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||||
@@ -448,21 +468,92 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the three date-precision fields only when the DTO carries them.
|
* Decides the title to persist on an edit (#726). The submitted title is the catalog
|
||||||
* A null field means "not submitted" — overwriting the stored value with null
|
* auto-title only when it equals {@code autoBefore} (built from the stored state) — an exact
|
||||||
* would fabricate a precision the user never chose, the exact dishonesty #666
|
* comparison with no heuristic, relying on the edit form round-tripping the stored title
|
||||||
* exists to prevent. A row with a genuinely-unknown precision must keep it when
|
* verbatim when untouched. A machine title is rebuilt from the new state so a corrected
|
||||||
* an unrelated edit (e.g. a location typo) is saved.
|
* 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).
|
||||||
*/
|
*/
|
||||||
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
|
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||||
if (dto.getMetaDatePrecision() != null) {
|
doc.setMetaDatePrecision(effectivePrecision(doc, dto));
|
||||||
doc.setMetaDatePrecision(dto.getMetaDatePrecision());
|
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.getMetaDateEnd() != null) {
|
// Mirrors chk_meta_date_end_only_for_range. API-only: the edit form clears the
|
||||||
doc.setMetaDateEnd(dto.getMetaDateEnd());
|
// end field off-RANGE, so this branch closes the same 500 class for direct clients.
|
||||||
}
|
if (doc.getMetaDateEnd() != null && doc.getMetaDatePrecision() != DatePrecision.RANGE) {
|
||||||
if (dto.getMetaDateRaw() != null) {
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
|
||||||
doc.setMetaDateRaw(dto.getMetaDateRaw());
|
"meta_date_end is only allowed when meta_date_precision is RANGE");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,18 +591,15 @@ public class DocumentService {
|
|||||||
* round-trip.
|
* round-trip.
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
public List<UUID> findIdsForFilter(SearchFilters filters) {
|
||||||
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator,
|
boolean hasText = StringUtils.hasText(filters.text());
|
||||||
boolean undated) {
|
|
||||||
boolean hasText = StringUtils.hasText(text);
|
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
|
||||||
if (rankedIds.isEmpty()) return List.of();
|
if (rankedIds.isEmpty()) return List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
Specification<Document> spec = buildSearchSpec(
|
Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
|
||||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
|
|
||||||
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
|
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,23 +609,18 @@ public class DocumentService {
|
|||||||
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
|
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
|
||||||
* full-text query returned no rows.
|
* full-text query returned no rows.
|
||||||
*/
|
*/
|
||||||
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
|
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
|
||||||
LocalDate from, LocalDate to,
|
boolean useOrLogic = filters.tagOperator() == TagOperator.OR;
|
||||||
UUID sender, UUID receiver,
|
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(filters.tags());
|
||||||
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;
|
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
|
||||||
return Specification.where(textSpec)
|
return Specification.where(textSpec)
|
||||||
.and(isBetween(from, to))
|
.and(isBetween(filters.from(), filters.to()))
|
||||||
.and(hasSender(sender))
|
.and(hasSender(filters.sender()))
|
||||||
.and(hasReceiver(receiver))
|
.and(hasReceiver(filters.receiver()))
|
||||||
.and(hasTags(expandedTagSets, useOrLogic))
|
.and(hasTags(expandedTagSets, useOrLogic))
|
||||||
.and(hasTagPartial(tagQ))
|
.and(hasTagPartial(filters.tagQ()))
|
||||||
.and(hasStatus(status))
|
.and(hasStatus(filters.status()))
|
||||||
.and(undatedOnly(undated));
|
.and(undatedOnly(filters.undated()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -666,8 +749,8 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||||
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) {
|
public DocumentSearchResult searchDocuments(SearchFilters filters, DocumentSort sort, String dir, Pageable pageable) {
|
||||||
boolean hasText = StringUtils.hasText(text);
|
boolean hasText = StringUtils.hasText(filters.text());
|
||||||
|
|
||||||
// Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip
|
// Pure-text RELEVANCE: push pagination + ts_rank ordering into SQL — skip
|
||||||
// findAllMatchingIdsByFts entirely (ADR-008). This must run BEFORE any
|
// findAllMatchingIdsByFts entirely (ADR-008). This must run BEFORE any
|
||||||
@@ -677,13 +760,13 @@ public class DocumentService {
|
|||||||
// no date/sender/receiver/tag/status filters, and undated documents are valid
|
// 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
|
// FTS hits already folded into the ranked page, so there is no separate undated
|
||||||
// count to report here.
|
// count to report here.
|
||||||
if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
|
if (!filters.undated() && isPureTextRelevance(hasText, sort, filters)) {
|
||||||
return relevanceSortedPageFromSql(text, pageable);
|
return relevanceSortedPageFromSql(filters.text(), pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<UUID> rankedIds = null;
|
List<UUID> rankedIds = null;
|
||||||
if (hasText) {
|
if (hasText) {
|
||||||
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
|
||||||
// FTS matched nothing → no results and, by definition, no undated matches either.
|
// FTS matched nothing → no results and, by definition, no undated matches either.
|
||||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||||
}
|
}
|
||||||
@@ -691,37 +774,32 @@ public class DocumentService {
|
|||||||
// Global undated count for the current filter (q/tags/sender/receiver/status),
|
// Global undated count for the current filter (q/tags/sender/receiver/status),
|
||||||
// forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so
|
// 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).
|
// it never collapses to the page slice and never double-counts (issue #668).
|
||||||
long undatedCount = countUndatedForFilter(hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
|
long undatedCount = countUndatedForFilter(hasText, rankedIds, filters.withUndated(true));
|
||||||
|
|
||||||
return runSearch(text, hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, sort, dir, tagOperator, undated, pageable)
|
return runSearch(hasText, rankedIds, filters, sort, dir, pageable)
|
||||||
.withUndatedCount(undatedCount);
|
.withUndatedCount(undatedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Counts every undated document (meta_date IS NULL) matching the active filter,
|
* Counts every undated document (meta_date IS NULL) matching the active filter,
|
||||||
* across all pages, independent of the undated toggle. Reuses {@link #buildSearchSpec}
|
* across all pages, independent of the undated toggle. The caller passes
|
||||||
* with {@code undated=true} forced so the count tracks q/tags/sender/receiver/status.
|
* {@code filters.withUndated(true)} so the count tracks q/tags/sender/receiver/status
|
||||||
* A {@code from}/{@code to} range excludes undated rows by the collision rule (#668),
|
* regardless of the user's "Nur undatierte" toggle. A {@code from}/{@code to} range
|
||||||
* so the count is legitimately 0 inside a date 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,
|
private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
|
||||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
Specification<Document> undatedSpec = buildSearchSpec(hasText, ftsIds, filters);
|
||||||
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);
|
return documentRepository.count(undatedSpec);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The original search dispatch — produces the page slice + totals, sans undated count. */
|
/** The original search dispatch — produces the page slice + totals, sans undated count. */
|
||||||
private DocumentSearchResult runSearch(String text, boolean hasText, List<UUID> rankedIds,
|
private DocumentSearchResult runSearch(boolean hasText, List<UUID> rankedIds, SearchFilters filters,
|
||||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
DocumentSort sort, String dir, Pageable pageable) {
|
||||||
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)
|
// The pure-text RELEVANCE fast path is handled by the caller (searchDocuments)
|
||||||
// before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008).
|
// before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008).
|
||||||
Specification<Document> spec = buildSearchSpec(
|
Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
|
||||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
|
String text = filters.text();
|
||||||
|
|
||||||
// SENDER and RECEIVER sorts load the full match set and slice in-memory.
|
// 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
|
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
|
||||||
@@ -755,12 +833,12 @@ public class DocumentService {
|
|||||||
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort,
|
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort, SearchFilters filters) {
|
||||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
|
||||||
List<String> tags, String tagQ, DocumentStatus status) {
|
|
||||||
return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
|
return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
|
||||||
&& from == null && to == null && sender == null && receiver == null
|
&& filters.from() == null && filters.to() == null
|
||||||
&& (tags == null || tags.isEmpty()) && (tagQ == null || tagQ.isBlank()) && status == null;
|
&& filters.sender() == null && filters.receiver() == null
|
||||||
|
&& (filters.tags() == null || filters.tags().isEmpty())
|
||||||
|
&& (filters.tagQ() == null || filters.tagQ().isBlank()) && filters.status() == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -775,14 +853,14 @@ public class DocumentService {
|
|||||||
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
||||||
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
||||||
|
|
||||||
// Preserve ts_rank order from SQL across the JPA findAllById call.
|
// Preserve ts_rank order from SQL across the JPA findByIdIn call.
|
||||||
Map<UUID, Integer> rankMap = new HashMap<>();
|
Map<UUID, Integer> rankMap = new HashMap<>();
|
||||||
List<UUID> pageIds = new ArrayList<>();
|
List<UUID> pageIds = new ArrayList<>();
|
||||||
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
||||||
rankMap.put(ftsPage.hits().get(i).id(), i);
|
rankMap.put(ftsPage.hits().get(i).id(), i);
|
||||||
pageIds.add(ftsPage.hits().get(i).id());
|
pageIds.add(ftsPage.hits().get(i).id());
|
||||||
}
|
}
|
||||||
List<Document> docs = documentRepository.findAllById(pageIds).stream()
|
List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
|
||||||
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
||||||
.toList();
|
.toList();
|
||||||
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
||||||
@@ -901,22 +979,6 @@ public class DocumentService {
|
|||||||
.orElse("");
|
.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
|
@Transactional
|
||||||
public void updateScriptType(UUID documentId, ScriptType scriptType) {
|
public void updateScriptType(UUID documentId, ScriptType scriptType) {
|
||||||
Document doc = getDocumentById(documentId);
|
Document doc = getDocumentById(documentId);
|
||||||
@@ -946,6 +1008,41 @@ public class DocumentService {
|
|||||||
return doc;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
public List<Document> getDocumentsByIds(List<UUID> ids) {
|
public List<Document> getDocumentsByIds(List<UUID> ids) {
|
||||||
return documentRepository.findAllById(ids);
|
return documentRepository.findAllById(ids);
|
||||||
}
|
}
|
||||||
@@ -962,13 +1059,26 @@ public class DocumentService {
|
|||||||
return documentRepository.findByReceiversId(receiverId);
|
return documentRepository.findByReceiversId(receiverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
|
public DocumentSearchResult searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable) {
|
||||||
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
Person person = personService.getById(personId);
|
||||||
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
Specification<Document> spec = buildPersonSpec(person, from, to);
|
||||||
if (receiverId == null) {
|
Page<Document> page = documentRepository.findAll(spec, pageable);
|
||||||
return documentRepository.findSinglePersonCorrespondence(senderId, dateFrom, dateTo, sort);
|
List<DocumentListItem> items = enrichItems(page.getContent(), null);
|
||||||
}
|
return DocumentSearchResult.paged(items, pageable, page.getTotalElements());
|
||||||
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
}
|
||||||
|
|
||||||
|
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 long getIncompleteCount() {
|
public long getIncompleteCount() {
|
||||||
@@ -989,11 +1099,13 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deleteDocument(UUID id) {
|
public void deleteDocument(UUID id, UUID actorId) {
|
||||||
if (!documentRepository.existsById(id)) {
|
if (!documentRepository.existsById(id)) {
|
||||||
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
||||||
}
|
}
|
||||||
|
eventPublisher.publishEvent(new DocumentDeletingEvent(id));
|
||||||
documentRepository.deleteById(id);
|
documentRepository.deleteById(id);
|
||||||
|
auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, actorId, id, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -1005,6 +1117,43 @@ public class DocumentService {
|
|||||||
tagService.delete(tagId);
|
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
|
@Transactional
|
||||||
public int backfillFileHashes() {
|
public int backfillFileHashes() {
|
||||||
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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("|", "(?:", ")"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ public class TranscriptionBlockQueryService {
|
|||||||
|
|
||||||
private final TranscriptionBlockRepository blockRepository;
|
private final TranscriptionBlockRepository blockRepository;
|
||||||
|
|
||||||
|
public boolean hasBlocks(UUID documentId) {
|
||||||
|
return blockRepository.existsByDocumentId(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
|
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
|
||||||
if (documentIds.isEmpty()) return Map.of();
|
if (documentIds.isEmpty()) return Map.of();
|
||||||
Map<UUID, Integer> result = new HashMap<>();
|
Map<UUID, Integer> result = new HashMap<>();
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
|||||||
|
|
||||||
int countByDocumentId(UUID documentId);
|
int countByDocumentId(UUID documentId);
|
||||||
|
|
||||||
|
boolean existsByDocumentId(UUID documentId);
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT b FROM TranscriptionBlock b
|
SELECT b FROM TranscriptionBlock b
|
||||||
JOIN DocumentAnnotation a ON a.id = b.annotationId
|
JOIN DocumentAnnotation a ON a.id = b.annotationId
|
||||||
|
|||||||
@@ -78,4 +78,8 @@ public class DomainException extends RuntimeException {
|
|||||||
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
||||||
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public enum ErrorCode {
|
|||||||
ALIAS_NOT_FOUND,
|
ALIAS_NOT_FOUND,
|
||||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||||
INVALID_PERSON_TYPE,
|
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 ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
DOCUMENT_NOT_FOUND,
|
DOCUMENT_NOT_FOUND,
|
||||||
@@ -26,6 +30,8 @@ public enum ErrorCode {
|
|||||||
FILE_UPLOAD_FAILED,
|
FILE_UPLOAD_FAILED,
|
||||||
/** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */
|
/** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */
|
||||||
UNSUPPORTED_FILE_TYPE,
|
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 ---
|
// --- Users ---
|
||||||
/** A user with the given ID or username does not exist. 404 */
|
/** A user with the given ID or username does not exist. 404 */
|
||||||
@@ -120,6 +126,22 @@ public enum ErrorCode {
|
|||||||
// --- Geschichten (Stories) ---
|
// --- Geschichten (Stories) ---
|
||||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||||
GESCHICHTE_NOT_FOUND,
|
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 ---
|
// --- Tags ---
|
||||||
/** A tag with the given ID does not exist. 404 */
|
/** A tag with the given ID does not exist. 404 */
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import io.sentry.Sentry;
|
|||||||
import jakarta.validation.ConstraintViolationException;
|
import jakarta.validation.ConstraintViolationException;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
@@ -64,6 +65,45 @@ public class GlobalExceptionHandler {
|
|||||||
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason()));
|
.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)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
||||||
Sentry.captureException(ex);
|
Sentry.captureException(ex);
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import jakarta.persistence.*;
|
|||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||||
import org.raddatz.familienarchiv.user.AppUser;
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -40,6 +42,12 @@ public class Geschichte {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private GeschichteType type = GeschichteType.STORY;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "author_id")
|
@JoinColumn(name = "author_id")
|
||||||
private AppUser author;
|
private AppUser author;
|
||||||
@@ -51,12 +59,18 @@ public class Geschichte {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<Person> persons = new HashSet<>();
|
private Set<Person> persons = new HashSet<>();
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
// LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.md. open-in-view is FALSE
|
||||||
@JoinTable(name = "geschichten_documents",
|
// (application.yaml), so this collection is DEAD at Jackson serialization time unless
|
||||||
joinColumns = @JoinColumn(name = "geschichte_id"),
|
// explicitly initialized inside the service transaction. getById() is
|
||||||
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
// @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")
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<Document> documents = new HashSet<>();
|
private List<JourneyItem> items = new ArrayList<>();
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(updatable = false)
|
@Column(updatable = false)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package org.raddatz.familienarchiv.geschichte;
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
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.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
@@ -14,6 +17,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.PatchMapping;
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -28,12 +32,17 @@ import java.util.UUID;
|
|||||||
public class GeschichteController {
|
public class GeschichteController {
|
||||||
|
|
||||||
private final GeschichteService geschichteService;
|
private final GeschichteService geschichteService;
|
||||||
|
private final JourneyItemService journeyItemService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<Geschichte> list(
|
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.")
|
||||||
@RequestParam(required = false) GeschichteStatus status,
|
@RequestParam(required = false) GeschichteStatus status,
|
||||||
|
@Parameter(description = "AND-filter: story must include all supplied person IDs.")
|
||||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
||||||
|
@Parameter(description = "Filter to stories containing this document.")
|
||||||
@RequestParam(required = false) UUID documentId,
|
@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) {
|
@RequestParam(required = false, defaultValue = "50") int limit) {
|
||||||
return geschichteService.list(
|
return geschichteService.list(
|
||||||
status,
|
status,
|
||||||
@@ -43,20 +52,20 @@ public class GeschichteController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Geschichte getById(@PathVariable UUID id) {
|
public GeschichteView getById(@PathVariable UUID id) {
|
||||||
return geschichteService.getById(id);
|
return geschichteService.getView(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
|
public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||||
Geschichte created = geschichteService.create(dto);
|
GeschichteView created = geschichteService.create(dto);
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/{id}")
|
@PatchMapping("/{id}")
|
||||||
@RequirePermission(Permission.BLOG_WRITE)
|
@RequirePermission(Permission.BLOG_WRITE)
|
||||||
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||||
return geschichteService.update(id, dto);
|
return geschichteService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,4 +75,45 @@ public class GeschichteController {
|
|||||||
geschichteService.delete(id);
|
geschichteService.delete(id);
|
||||||
return ResponseEntity.noContent().build();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,47 @@
|
|||||||
package org.raddatz.familienarchiv.geschichte;
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
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 org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,28 +4,23 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.owasp.html.HtmlPolicyBuilder;
|
import org.owasp.html.HtmlPolicyBuilder;
|
||||||
import org.owasp.html.PolicyFactory;
|
import org.owasp.html.PolicyFactory;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
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.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.person.Person;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
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.Authentication;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -41,6 +36,7 @@ public class GeschichteService {
|
|||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final JourneyItemService journeyItemService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
||||||
@@ -54,12 +50,26 @@ public class GeschichteService {
|
|||||||
private static final int DEFAULT_LIMIT = 50;
|
private static final int DEFAULT_LIMIT = 50;
|
||||||
private static final int MAX_LIMIT = 200;
|
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 ────────────────────────────────────────────────────────────
|
// ─── Read API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public long countPublished() {
|
public long countPublished() {
|
||||||
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
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) {
|
public Geschichte getById(UUID id) {
|
||||||
Geschichte g = geschichteRepository.findById(id)
|
Geschichte g = geschichteRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
@@ -72,24 +82,62 @@ public class GeschichteService {
|
|||||||
return g;
|
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
|
* 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
|
* 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}.
|
* 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<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
boolean isDraftRequest = currentUserHasBlogWrite() && status == GeschichteStatus.DRAFT;
|
||||||
|
GeschichteStatus effective = isDraftRequest ? GeschichteStatus.DRAFT : GeschichteStatus.PUBLISHED;
|
||||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||||
|
|
||||||
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
|
UUID authorId = isDraftRequest ? currentUser().getId() : null;
|
||||||
Specification<Geschichte> spec = Specification.allOf(
|
|
||||||
GeschichteSpecifications.hasStatus(effective),
|
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
|
||||||
GeschichteSpecifications.hasAuthor(authorId),
|
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
|
||||||
GeschichteSpecifications.hasAllPersons(personIds),
|
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
|
||||||
GeschichteSpecifications.hasDocument(documentId),
|
? List.of(NIL_UUID)
|
||||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
: personIds;
|
||||||
);
|
long personCount = (personIds == null) ? 0 : personIds.size();
|
||||||
return geschichteRepository.findAll(spec, Sort.unsorted())
|
|
||||||
|
return geschichteRepository
|
||||||
|
.findSummaries(effective, authorId, safePersonIds, personCount, documentId)
|
||||||
.stream()
|
.stream()
|
||||||
.limit(safeLimit)
|
.limit(safeLimit)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -97,46 +145,57 @@ public class GeschichteService {
|
|||||||
|
|
||||||
// ─── Write API ───────────────────────────────────────────────────────────
|
// ─── 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
|
@Transactional
|
||||||
public Geschichte create(GeschichteUpdateDTO dto) {
|
public GeschichteView create(GeschichteUpdateDTO dto) {
|
||||||
requireTitle(dto.getTitle());
|
requireTitle(dto.getTitle());
|
||||||
|
GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
|
||||||
Geschichte g = Geschichte.builder()
|
Geschichte g = Geschichte.builder()
|
||||||
.title(dto.getTitle().trim())
|
.title(dto.getTitle().trim())
|
||||||
.body(sanitize(dto.getBody()))
|
.body(bodyForType(type, dto.getBody()))
|
||||||
.status(GeschichteStatus.DRAFT)
|
.status(GeschichteStatus.DRAFT)
|
||||||
|
.type(type)
|
||||||
.author(currentUser())
|
.author(currentUser())
|
||||||
.persons(resolvePersons(dto.getPersonIds()))
|
.persons(resolvePersons(dto.getPersonIds()))
|
||||||
.documents(resolveDocuments(dto.getDocumentIds()))
|
|
||||||
.build();
|
.build();
|
||||||
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
||||||
g.setStatus(GeschichteStatus.PUBLISHED);
|
g.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
g.setPublishedAt(LocalDateTime.now());
|
g.setPublishedAt(LocalDateTime.now());
|
||||||
}
|
}
|
||||||
return geschichteRepository.save(g);
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
|
public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
|
||||||
Geschichte g = geschichteRepository.findById(id)
|
Geschichte g = geschichteRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
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) {
|
if (dto.getTitle() != null) {
|
||||||
requireTitle(dto.getTitle());
|
requireTitle(dto.getTitle());
|
||||||
g.setTitle(dto.getTitle().trim());
|
g.setTitle(dto.getTitle().trim());
|
||||||
}
|
}
|
||||||
if (dto.getBody() != null) {
|
if (dto.getBody() != null) {
|
||||||
g.setBody(sanitize(dto.getBody()));
|
g.setBody(bodyForType(g.getType(), dto.getBody()));
|
||||||
}
|
}
|
||||||
if (dto.getPersonIds() != null) {
|
if (dto.getPersonIds() != null) {
|
||||||
g.setPersons(resolvePersons(dto.getPersonIds()));
|
g.setPersons(resolvePersons(dto.getPersonIds()));
|
||||||
}
|
}
|
||||||
if (dto.getDocumentIds() != null) {
|
|
||||||
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
|
|
||||||
}
|
|
||||||
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
||||||
applyStatusTransition(g, dto.getStatus());
|
applyStatusTransition(g, dto.getStatus());
|
||||||
}
|
}
|
||||||
return geschichteRepository.save(g);
|
Geschichte saved = geschichteRepository.save(g);
|
||||||
|
return toView(saved, journeyItemService.getItems(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -164,6 +223,27 @@ public class GeschichteService {
|
|||||||
throw DomainException.badRequest(
|
throw DomainException.badRequest(
|
||||||
ErrorCode.VALIDATION_ERROR, "Title is required");
|
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 ("&" → "&") 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) {
|
private String sanitize(String body) {
|
||||||
@@ -176,15 +256,6 @@ public class GeschichteService {
|
|||||||
return new LinkedHashSet<>(personService.getAllById(ids));
|
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() {
|
private AppUser currentUser() {
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
if (auth == null || !auth.isAuthenticated()) {
|
if (auth == null || !auth.isAuthenticated()) {
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import jakarta.persistence.criteria.Join;
|
|||||||
import jakarta.persistence.criteria.Predicate;
|
import jakarta.persistence.criteria.Predicate;
|
||||||
import jakarta.persistence.criteria.Root;
|
import jakarta.persistence.criteria.Root;
|
||||||
import jakarta.persistence.criteria.Subquery;
|
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.raddatz.familienarchiv.person.Person;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
@@ -48,12 +45,7 @@ public final class GeschichteSpecifications {
|
|||||||
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands
|
||||||
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}.
|
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
|
||||||
@@ -84,14 +76,4 @@ public final class GeschichteSpecifications {
|
|||||||
return sub;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
|
public enum GeschichteType {
|
||||||
|
STORY,
|
||||||
|
JOURNEY
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.geschichte;
|
package org.raddatz.familienarchiv.geschichte;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -16,6 +15,6 @@ public class GeschichteUpdateDTO {
|
|||||||
private String title;
|
private String title;
|
||||||
private String body;
|
private String body;
|
||||||
private GeschichteStatus status;
|
private GeschichteStatus status;
|
||||||
|
private GeschichteType type;
|
||||||
private List<UUID> personIds;
|
private List<UUID> personIds;
|
||||||
private List<UUID> documentIds;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
|
||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -74,6 +75,7 @@ public class DocumentImporter {
|
|||||||
Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?");
|
Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?");
|
||||||
|
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
|
private final DocumentTitleFactory documentTitleFactory;
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
private final S3Client s3Client;
|
private final S3Client s3Client;
|
||||||
@@ -181,7 +183,7 @@ public class DocumentImporter {
|
|||||||
applyAttribution(doc, row);
|
applyAttribution(doc, row);
|
||||||
applyDates(doc, row);
|
applyDates(doc, row);
|
||||||
applyAuthoritativeAssociations(doc, row);
|
applyAuthoritativeAssociations(doc, row);
|
||||||
applyFileMetadata(doc, s3Key, contentType, status, index);
|
applyFileMetadata(doc, s3Key, contentType, status);
|
||||||
applyComputedFlags(doc);
|
applyComputedFlags(doc);
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
@@ -217,14 +219,15 @@ public class DocumentImporter {
|
|||||||
attachTag(doc, row.get("tags"));
|
attachTag(doc, row.get("tags"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// S3 key, content type, status, and the index-derived title.
|
// 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.
|
||||||
private void applyFileMetadata(Document doc, String s3Key, String contentType,
|
private void applyFileMetadata(Document doc, String s3Key, String contentType,
|
||||||
DocumentStatus status, String index) {
|
DocumentStatus status) {
|
||||||
doc.setStatus(status);
|
doc.setStatus(status);
|
||||||
doc.setFilePath(s3Key);
|
doc.setFilePath(s3Key);
|
||||||
doc.setContentType(contentType);
|
doc.setContentType(contentType);
|
||||||
doc.setTitle(buildTitle(index, doc.getDocumentDate(), doc.getMetaDatePrecision(),
|
doc.setTitle(documentTitleFactory.build(doc));
|
||||||
doc.getMetaDateEnd(), doc.getMetaDateRaw(), doc.getLocation()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// metadataComplete: a document counts as fully described if any of the three "who/when"
|
// metadataComplete: a document counts as fully described if any of the three "who/when"
|
||||||
@@ -235,20 +238,6 @@ public class DocumentImporter {
|
|||||||
|| !doc.getReceivers().isEmpty());
|
|| !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 ─────────────────────
|
// ─── attribution routing — register-first, always retain raw ─────────────────────
|
||||||
|
|
||||||
private Person resolveSender(String slug, String rawName) {
|
private Person resolveSender(String slug, String rawName) {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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) {
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.user.DisplayNameFormatter;
|
import org.raddatz.familienarchiv.user.DisplayNameFormatter;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -49,8 +51,25 @@ public class Person {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|
||||||
private Integer birthYear;
|
// Most precise birth/death date known. Precision mirrors Document.metaDatePrecision:
|
||||||
private Integer deathYear;
|
// 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;
|
||||||
|
|
||||||
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
|
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
|
||||||
// Nullable for persons outside the curated family graph. Drives the
|
// Nullable for persons outside the curated family graph. Drives the
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
"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(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||||
|
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||||
"ORDER BY p.lastName ASC, p.firstName ASC")
|
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||||
List<Person> searchByName(@Param("query") String query);
|
List<Person> searchByName(@Param("query") String query);
|
||||||
|
|
||||||
@@ -29,21 +30,46 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
||||||
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||||
|
|
||||||
// Lookup by full alias string, used during ODS mass import
|
// Exact-case alias lookup — the first resolution step in findOrCreateByAlias.
|
||||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
// 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 the normalizer person_id, used for idempotent canonical re-import (Phase 3).
|
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
|
||||||
Optional<Person> findBySourceRef(String sourceRef);
|
Optional<Person> findBySourceRef(String sourceRef);
|
||||||
|
|
||||||
// Exact first+last name match, used for filename-based sender lookup
|
// Exact-case first+last name match — the first step of filename-based sender resolution.
|
||||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
// 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);
|
||||||
|
|
||||||
// --- PersonSummaryDTO with document count ---
|
// --- PersonSummaryDTO with document count ---
|
||||||
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
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.family_member AS familyMember, p.provisional AS provisional,
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(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
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
@@ -56,7 +82,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
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.family_member AS familyMember, p.provisional AS provisional,
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(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
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
@@ -66,7 +95,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(a.last_name) 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_year, p.death_year, 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_date, p.birth_date_precision, p.death_date, p.death_date_precision, p.notes, p.family_member, p.provisional
|
||||||
ORDER BY p.last_name ASC, p.first_name ASC
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
""",
|
""",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
@@ -77,7 +106,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
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.family_member AS familyMember, p.provisional AS provisional,
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(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
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
@@ -116,7 +148,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
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.family_member AS familyMember, p.provisional AS provisional,
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(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
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
@@ -189,18 +224,15 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
|
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
|
||||||
|
|
||||||
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
|
// --- 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
|
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||||
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
|
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
|
||||||
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
|
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
|
||||||
|
|
||||||
// Used by deletePerson: detach a deleted person from documents they sent, so the hard
|
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||||
// 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 = """
|
@Query(value = """
|
||||||
INSERT INTO document_receivers (document_id, person_id)
|
INSERT INTO document_receivers (document_id, person_id)
|
||||||
SELECT document_id, :target FROM document_receivers
|
SELECT document_id, :target FROM document_receivers
|
||||||
@@ -210,8 +242,4 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
)
|
)
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
|
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);
|
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
package org.raddatz.familienarchiv.person;
|
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.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
||||||
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
@@ -23,11 +32,20 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class PersonService {
|
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 PersonRepository personRepository;
|
||||||
private final PersonNameAliasRepository aliasRepository;
|
private final PersonNameAliasRepository aliasRepository;
|
||||||
|
|
||||||
@@ -68,15 +86,13 @@ public class PersonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hard-deletes a person used by triage. Detaches the person from any documents they
|
* Hard-deletes a person used by triage. Referential integrity is enforced by the database
|
||||||
* sent (nulls sender_id) and from any received-document references first, so the delete
|
* (V71's {@code ON DELETE} constraints: sender_id {@code SET NULL}, receiver and @-mention
|
||||||
* cannot orphan an FK and fail with a 500.
|
* rows {@code CASCADE}), so the service stays thin — it only verifies existence then deletes.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public void deletePerson(UUID id) {
|
public void deletePerson(UUID id) {
|
||||||
getById(id);
|
getById(id);
|
||||||
personRepository.reassignSenderToNull(id);
|
|
||||||
personRepository.deleteReceiverReferences(id);
|
|
||||||
personRepository.deleteById(id);
|
personRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +116,96 @@ public class PersonService {
|
|||||||
return personRepository.findAllById(ids);
|
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() {
|
public List<Person> findAllFamilyMembers() {
|
||||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||||
}
|
}
|
||||||
@@ -112,7 +218,19 @@ public class PersonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Optional<Person> findByName(String firstName, String lastName) {
|
public Optional<Person> findByName(String firstName, String lastName) {
|
||||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lookup by the normalizer person_id — used by the canonical importer for register-first matching. */
|
/** Lookup by the normalizer person_id — used by the canonical importer for register-first matching. */
|
||||||
@@ -127,32 +245,45 @@ public class PersonService {
|
|||||||
PersonType type = PersonTypeClassifier.classify(alias);
|
PersonType type = PersonTypeClassifier.classify(alias);
|
||||||
if (type == PersonType.SKIP) return null;
|
if (type == PersonType.SKIP) return null;
|
||||||
|
|
||||||
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
|
// Aliases differing only by case (müller / Müller) are valid distinct persons, not
|
||||||
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
|
// duplicates, so a CASE-COLLISION must not throw: exact-case first, then the lowest-id
|
||||||
return personRepository.save(Person.builder()
|
// case-insensitive sibling, then create. Mirrors the tag path — see ADR-033.
|
||||||
.alias(alias)
|
// Scope (#731): "ambiguous" means case-insensitive. Two BYTE-IDENTICAL same-case aliases
|
||||||
.lastName(alias)
|
// are a true data anomaly out of scope here; the exact Optional below would surface that
|
||||||
.personType(type)
|
// as the opaque INTERNAL_ERROR (never a wrong row), not silently pick one.
|
||||||
.build());
|
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
|
||||||
|
}
|
||||||
|
|
||||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
// Create-when-absent: institution/group keep the full label in lastName; a person name
|
||||||
Person person = personRepository.save(Person.builder()
|
// is split and a maiden name (geb. …) becomes a MAIDEN_NAME alias.
|
||||||
|
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
|
||||||
|
return personRepository.save(Person.builder()
|
||||||
.alias(alias)
|
.alias(alias)
|
||||||
.firstName(split.firstName())
|
.lastName(alias)
|
||||||
.lastName(split.lastName())
|
.personType(type)
|
||||||
.build());
|
.build());
|
||||||
if (split.maidenName() != null) {
|
}
|
||||||
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
|
|
||||||
aliasRepository.save(PersonNameAlias.builder()
|
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||||
.person(person)
|
Person person = personRepository.save(Person.builder()
|
||||||
.lastName(split.maidenName())
|
.alias(alias)
|
||||||
.type(PersonNameAliasType.MAIDEN_NAME)
|
.firstName(split.firstName())
|
||||||
.sortOrder(nextSortOrder)
|
.lastName(split.lastName())
|
||||||
.build());
|
.build());
|
||||||
}
|
if (split.maidenName() != null) {
|
||||||
return person;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,13 +301,20 @@ public class PersonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Person fromCanonical(PersonUpsertCommand cmd) {
|
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()
|
Person person = personRepository.save(Person.builder()
|
||||||
.sourceRef(cmd.sourceRef())
|
.sourceRef(cmd.sourceRef())
|
||||||
.firstName(blankToNull(cmd.firstName()))
|
.firstName(blankToNull(cmd.firstName()))
|
||||||
.lastName(cmd.lastName())
|
.lastName(cmd.lastName())
|
||||||
.notes(blankToNull(cmd.notes()))
|
.notes(blankToNull(cmd.notes()))
|
||||||
.birthYear(cmd.birthYear())
|
.birthDate(birth.date())
|
||||||
.deathYear(cmd.deathYear())
|
.birthDatePrecision(birth.precision())
|
||||||
|
.deathDate(death.date())
|
||||||
|
.deathDatePrecision(death.precision())
|
||||||
.generation(cmd.generation())
|
.generation(cmd.generation())
|
||||||
.familyMember(cmd.familyMember())
|
.familyMember(cmd.familyMember())
|
||||||
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
|
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
|
||||||
@@ -199,8 +337,16 @@ public class PersonService {
|
|||||||
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
|
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
|
||||||
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
|
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
|
||||||
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
||||||
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
|
LifeDates dates = degradeIfConflicting(
|
||||||
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
|
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.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
|
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
|
||||||
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
|
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
|
||||||
existing.setPersonType(cmd.personType());
|
existing.setPersonType(cmd.personType());
|
||||||
@@ -227,6 +373,48 @@ public class PersonService {
|
|||||||
return existing != null ? existing : canonical;
|
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) {
|
private static String blankToNull(String s) {
|
||||||
return (s == null || s.isBlank()) ? null : s.trim();
|
return (s == null || s.isBlank()) ? null : s.trim();
|
||||||
}
|
}
|
||||||
@@ -246,7 +434,8 @@ public class PersonService {
|
|||||||
if (dto.getPersonType() == PersonType.SKIP) {
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
||||||
}
|
}
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
|
||||||
|
dto.getDeathDate(), dto.getDeathDatePrecision());
|
||||||
Person person = Person.builder()
|
Person person = Person.builder()
|
||||||
.personType(dto.getPersonType())
|
.personType(dto.getPersonType())
|
||||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||||
@@ -254,31 +443,49 @@ public class PersonService {
|
|||||||
.lastName(dto.getLastName())
|
.lastName(dto.getLastName())
|
||||||
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||||
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||||
.birthYear(dto.getBirthYear())
|
.birthDate(dto.getBirthDate())
|
||||||
.deathYear(dto.getDeathYear())
|
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
|
||||||
|
.deathDate(dto.getDeathDate())
|
||||||
|
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
|
||||||
.generation(dto.getGeneration())
|
.generation(dto.getGeneration())
|
||||||
.build();
|
.build();
|
||||||
return personRepository.save(person);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateYears(Integer birthYear, Integer deathYear) {
|
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
||||||
if (birthYear != null && birthYear <= 0) {
|
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
|
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);
|
||||||
}
|
}
|
||||||
if (deathYear != null && deathYear <= 0) {
|
}
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr 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 (birthYear != null && deathYear != null && birthYear > deathYear) {
|
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
|
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||||
|
side + " date precision " + precision + " is set without a date");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DatePrecision normalizePrecision(DatePrecision precision) {
|
||||||
|
return precision == null ? DatePrecision.UNKNOWN : precision;
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||||
if (dto.getPersonType() == PersonType.SKIP) {
|
if (dto.getPersonType() == PersonType.SKIP) {
|
||||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
||||||
}
|
}
|
||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
|
||||||
|
dto.getDeathDate(), dto.getDeathDatePrecision());
|
||||||
Person person = personRepository.findById(id)
|
Person person = personRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||||
person.setPersonType(dto.getPersonType());
|
person.setPersonType(dto.getPersonType());
|
||||||
@@ -287,14 +494,22 @@ public class PersonService {
|
|||||||
person.setLastName(dto.getLastName());
|
person.setLastName(dto.getLastName());
|
||||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
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.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||||
person.setBirthYear(dto.getBirthYear());
|
person.setBirthDate(dto.getBirthDate());
|
||||||
person.setDeathYear(dto.getDeathYear());
|
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
|
||||||
|
person.setDeathDate(dto.getDeathDate());
|
||||||
|
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
|
||||||
// Form path: a human can clear generation back to null. Unlike the importer
|
// Form path: a human can clear generation back to null. Unlike the importer
|
||||||
// which routes through preferHuman, we write the DTO value verbatim.
|
// which routes through preferHuman, we write the DTO value verbatim.
|
||||||
person.setGeneration(dto.getGeneration());
|
person.setGeneration(dto.getGeneration());
|
||||||
return personRepository.save(person);
|
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
|
@Transactional
|
||||||
public void mergePersons(UUID sourceId, UUID targetId) {
|
public void mergePersons(UUID sourceId, UUID targetId) {
|
||||||
if (sourceId.equals(targetId)) {
|
if (sourceId.equals(targetId)) {
|
||||||
@@ -311,9 +526,7 @@ public class PersonService {
|
|||||||
// Add target as receiver where source is receiver but target is not yet
|
// Add target as receiver where source is receiver but target is not yet
|
||||||
personRepository.insertMissingReceiverReference(sourceId, targetId);
|
personRepository.insertMissingReceiverReference(sourceId, targetId);
|
||||||
|
|
||||||
// Remove all remaining source receiver references (duplicates already handled)
|
// Source's remaining receiver rows cascade-drop via V71's ON DELETE CASCADE.
|
||||||
personRepository.deleteReceiverReferences(sourceId);
|
|
||||||
|
|
||||||
personRepository.deleteById(sourceId);
|
personRepository.deleteById(sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package org.raddatz.familienarchiv.person;
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +19,13 @@ public interface PersonSummaryDTO {
|
|||||||
String getAlias();
|
String getAlias();
|
||||||
Integer getBirthYear();
|
Integer getBirthYear();
|
||||||
Integer getDeathYear();
|
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();
|
String getNotes();
|
||||||
boolean isFamilyMember();
|
boolean isFamilyMember();
|
||||||
boolean isProvisional();
|
boolean isProvisional();
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import jakarta.validation.constraints.Min;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
import org.raddatz.familienarchiv.person.PersonType;
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PersonUpdateDTO {
|
public class PersonUpdateDTO {
|
||||||
@NotNull
|
@NotNull
|
||||||
@@ -21,8 +24,10 @@ public class PersonUpdateDTO {
|
|||||||
private String alias;
|
private String alias;
|
||||||
@Size(max = 5000)
|
@Size(max = 5000)
|
||||||
private String notes;
|
private String notes;
|
||||||
private Integer birthYear;
|
private LocalDate birthDate;
|
||||||
private Integer deathYear;
|
private DatePrecision birthDatePrecision;
|
||||||
|
private LocalDate deathDate;
|
||||||
|
private DatePrecision deathDatePrecision;
|
||||||
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
|
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
|
||||||
// PersonGeneration so DB, DTO, and importer all read from one place.
|
// PersonGeneration so DB, DTO, and importer all read from one place.
|
||||||
@Min(PersonGeneration.MIN_GENERATION)
|
@Min(PersonGeneration.MIN_GENERATION)
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
|
|||||||
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
|
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
|
||||||
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
||||||
| `findAll(String q)` | document, dashboard | List all persons |
|
| `findAll(String q)` | document, dashboard | List all persons |
|
||||||
| `findByName(String firstName, String lastName)` | document | Typeahead search |
|
| `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. |
|
||||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally |
|
| `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. |
|
||||||
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
||||||
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
||||||
| `count()` | dashboard | Total person count for stats |
|
| `count()` | dashboard | Total person count for stats |
|
||||||
|
|||||||
@@ -96,7 +96,9 @@ public class RelationshipInferenceService {
|
|||||||
if (p == null) continue;
|
if (p == null) continue;
|
||||||
List<RelationToken> path = shortestPaths.get(id);
|
List<RelationToken> path = shortestPaths.get(id);
|
||||||
PersonNodeDTO node = new PersonNodeDTO(
|
PersonNodeDTO node = new PersonNodeDTO(
|
||||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
p.getId(), p.getDisplayName(),
|
||||||
|
RelationshipService.yearOf(p.getBirthDate()),
|
||||||
|
RelationshipService.yearOf(p.getDeathDate()),
|
||||||
p.getGeneration(), p.isFamilyMember());
|
p.getGeneration(), p.isFamilyMember());
|
||||||
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.springframework.dao.DataIntegrityViolationException;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -66,7 +67,8 @@ public class RelationshipService {
|
|||||||
for (Person p : familyMembers) {
|
for (Person p : familyMembers) {
|
||||||
familyIds.add(p.getId());
|
familyIds.add(p.getId());
|
||||||
nodes.add(new PersonNodeDTO(
|
nodes.add(new PersonNodeDTO(
|
||||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
p.getId(), p.getDisplayName(),
|
||||||
|
yearOf(p.getBirthDate()), yearOf(p.getDeathDate()),
|
||||||
p.getGeneration(), true));
|
p.getGeneration(), true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +157,13 @@ public class RelationshipService {
|
|||||||
return (s == null || s.isBlank()) ? null : s.trim();
|
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) {
|
private static void validateYears(Integer fromYear, Integer toYear) {
|
||||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
if (fromYear != null && toYear != null && toYear < fromYear) {
|
||||||
throw DomainException.badRequest(
|
throw DomainException.badRequest(
|
||||||
@@ -170,11 +179,11 @@ public class RelationshipService {
|
|||||||
p.getId(),
|
p.getId(),
|
||||||
rp.getId(),
|
rp.getId(),
|
||||||
p.getDisplayName(),
|
p.getDisplayName(),
|
||||||
p.getBirthYear(),
|
yearOf(p.getBirthDate()),
|
||||||
p.getDeathYear(),
|
yearOf(p.getDeathDate()),
|
||||||
rp.getDisplayName(),
|
rp.getDisplayName(),
|
||||||
rp.getBirthYear(),
|
yearOf(rp.getBirthDate()),
|
||||||
rp.getDeathYear(),
|
yearOf(rp.getDeathDate()),
|
||||||
r.getRelationType(),
|
r.getRelationType(),
|
||||||
r.getFromYear(),
|
r.getFromYear(),
|
||||||
r.getToYear(),
|
r.getToYear(),
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ Hierarchical document categories. Tags form a tree via a self-referencing `paren
|
|||||||
Entity: `Tag` (self-referencing `parent_id` tree).
|
Entity: `Tag` (self-referencing `parent_id` tree).
|
||||||
Features: tag CRUD, hierarchical deletion (cascade to descendants), tag typeahead, admin tag management (rename, reparent, merge).
|
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
|
## What this domain does NOT own
|
||||||
|
|
||||||
- Documents — the `document_tags` join table is on the document side. `Tag` does not hold document references.
|
- Documents — the `document_tags` join table is on the document side. `Tag` does not hold document references.
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Optional<Tag> findByNameIgnoreCase(String name);
|
// 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);
|
||||||
|
|
||||||
// Lookup by the canonical tag_path, used for idempotent canonical re-import (Phase 3).
|
// Lookup by the canonical tag_path, used for idempotent canonical re-import (Phase 3).
|
||||||
Optional<Tag> findBySourceRef(String sourceRef);
|
Optional<Tag> findBySourceRef(String sourceRef);
|
||||||
@@ -126,4 +133,31 @@ 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)
|
@Query(value = "SELECT tag_id AS tagId, COUNT(*) AS count FROM document_tags GROUP BY tag_id", nativeQuery = true)
|
||||||
List<TagCount> findDocumentCountsPerTag();
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.tag;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -45,6 +46,10 @@ public class TagService {
|
|||||||
return enrichWithRelatives(matched);
|
return enrichWithRelatives(matched);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Tag> findByNameContaining(String fragment) {
|
||||||
|
return tagRepository.findByNameContainingIgnoreCase(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
public Tag getById(UUID id) {
|
public Tag getById(UUID id) {
|
||||||
return tagRepository.findById(id)
|
return tagRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
||||||
@@ -55,10 +60,21 @@ public class TagService {
|
|||||||
return tagRepository.findBySourceRef(sourceRef);
|
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) {
|
public Tag findOrCreate(String name) {
|
||||||
String cleanName = name.trim();
|
String cleanName = name.trim();
|
||||||
return tagRepository.findByNameIgnoreCase(cleanName)
|
Optional<Tag> exact = tagRepository.findByName(cleanName);
|
||||||
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,19 +188,27 @@ public class TagService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all tags assembled into a tree with document counts per node.
|
* Returns all tags assembled into a tree, each node carrying two counts:
|
||||||
* Uses a single aggregate query to avoid N+1 behaviour.
|
* {@code documentCount} — documents tagged with that exact tag (direct) — and
|
||||||
* NOTE: document counts are global per tag, not scoped to any search filter.
|
* {@code subtreeDocumentCount} — distinct documents tagged with that tag or any descendant
|
||||||
* The tree endpoint is only used for the admin sidebar, so this is intentional.
|
* (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).
|
||||||
*/
|
*/
|
||||||
public List<TagTreeNodeDTO> getTagTree() {
|
public List<TagTreeNodeDTO> getTagTree() {
|
||||||
List<Tag> all = tagRepository.findAll();
|
List<Tag> all = tagRepository.findAll();
|
||||||
Map<UUID, Long> counts = tagRepository.findDocumentCountsPerTag().stream()
|
Map<UUID, Long> counts = toCountMap(tagRepository.findDocumentCountsPerTag());
|
||||||
.collect(Collectors.toMap(
|
Map<UUID, Long> subtreeCounts = toCountMap(tagRepository.findSubtreeDocumentCountsPerTag());
|
||||||
TagRepository.TagCount::getTagId,
|
return buildTree(all, counts, subtreeCounts);
|
||||||
TagRepository.TagCount::getCount
|
}
|
||||||
));
|
|
||||||
return buildTree(all, counts);
|
private static Map<UUID, Long> toCountMap(List<TagRepository.TagCount> counts) {
|
||||||
|
return counts.stream().collect(Collectors.toMap(
|
||||||
|
TagRepository.TagCount::getTagId,
|
||||||
|
TagRepository.TagCount::getCount
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── private helpers ─────────────────────────────────────────────────────
|
// ─── private helpers ─────────────────────────────────────────────────────
|
||||||
@@ -259,12 +283,14 @@ public class TagService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts) {
|
private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts,
|
||||||
|
Map<UUID, Long> subtreeCounts) {
|
||||||
Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
|
Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
|
||||||
for (Tag tag : tags) {
|
for (Tag tag : tags) {
|
||||||
int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
|
int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
|
||||||
|
int subtreeDocumentCount = subtreeCounts.getOrDefault(tag.getId(), 0L).intValue();
|
||||||
nodeById.put(tag.getId(), new TagTreeNodeDTO(
|
nodeById.put(tag.getId(), new TagTreeNodeDTO(
|
||||||
tag.getId(), tag.getName(), tag.getColor(), documentCount,
|
tag.getId(), tag.getName(), tag.getColor(), documentCount, subtreeDocumentCount,
|
||||||
new ArrayList<>(), tag.getParentId()
|
new ArrayList<>(), tag.getParentId()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,8 @@ public record TagTreeNodeDTO(
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
|
||||||
String color,
|
String color,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount,
|
@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,
|
List<TagTreeNodeDTO> children,
|
||||||
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}
|
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ public class AdminController {
|
|||||||
return ResponseEntity.ok(new BackfillResult(count));
|
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")
|
@PostMapping("/generate-thumbnails")
|
||||||
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
|
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
|
||||||
thumbnailBackfillService.runBackfillAsync();
|
thumbnailBackfillService.runBackfillAsync();
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ springdoc:
|
|||||||
swagger-ui:
|
swagger-ui:
|
||||||
enabled: true
|
enabled: true
|
||||||
path: /swagger-ui.html
|
path: /swagger-ui.html
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 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);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- 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);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 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);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- 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;
|
||||||
@@ -38,7 +38,6 @@ import java.util.UUID;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -76,7 +75,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_returns200_whenAuthenticated() throws Exception {
|
void search_returns200_whenAuthenticated() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
@@ -88,7 +87,7 @@ class DocumentControllerTest {
|
|||||||
void search_undatedTrue_isReachableByAuthenticatedUser() throws Exception {
|
void search_undatedTrue_isReachableByAuthenticatedUser() throws Exception {
|
||||||
// The read GET must stay reachable for READ_ALL users — guards against a
|
// The read GET must stay reachable for READ_ALL users — guards against a
|
||||||
// future refactor accidentally write-guarding the undated triage path (#668).
|
// future refactor accidentally write-guarding the undated triage path (#668).
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("undated", "true"))
|
mockMvc.perform(get("/api/documents/search").param("undated", "true"))
|
||||||
@@ -104,41 +103,43 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_undatedTrue_isForwardedToServiceAsTrue() throws Exception {
|
void search_undatedTrue_isForwardedToServiceAsTrue() throws Exception {
|
||||||
ArgumentCaptor<Boolean> undatedCaptor = ArgumentCaptor.forClass(Boolean.class);
|
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("undated", "true"))
|
mockMvc.perform(get("/api/documents/search").param("undated", "true"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), undatedCaptor.capture(), any());
|
verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
|
||||||
assertThat(undatedCaptor.getValue()).isTrue();
|
assertThat(filtersCaptor.getValue().undated()).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_withoutUndatedParam_forwardsFalseToService() throws Exception {
|
void search_withoutUndatedParam_forwardsFalseToService() throws Exception {
|
||||||
ArgumentCaptor<Boolean> undatedCaptor = ArgumentCaptor.forClass(Boolean.class);
|
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), undatedCaptor.capture(), any());
|
verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
|
||||||
assertThat(undatedCaptor.getValue()).isFalse();
|
assertThat(filtersCaptor.getValue().undated()).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_withStatusParam_passesItToService() throws Exception {
|
void search_withStatusParam_passesItToService() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), anyBoolean(), any()))
|
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), anyBoolean(), any());
|
verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
|
||||||
|
assertThat(filtersCaptor.getValue().status()).isEqualTo(DocumentStatus.REVIEWED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -165,7 +166,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_responseContainsTotalCount() throws Exception {
|
void search_responseContainsTotalCount() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
@@ -180,7 +181,7 @@ class DocumentControllerTest {
|
|||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
var matchData = new SearchMatchData(
|
var matchData = new SearchMatchData(
|
||||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||||
docId, "Brief an Anna", "brief.pdf", null, null,
|
docId, "Brief an Anna", "brief.pdf", null, null,
|
||||||
DatePrecision.UNKNOWN, null, null,
|
DatePrecision.UNKNOWN, null, null,
|
||||||
@@ -200,7 +201,7 @@ class DocumentControllerTest {
|
|||||||
void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
|
void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
|
||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||||
docId, "Brief an Anna", "brief.pdf", null, null,
|
docId, "Brief an Anna", "brief.pdf", null, null,
|
||||||
DatePrecision.UNKNOWN, null, null,
|
DatePrecision.UNKNOWN, null, null,
|
||||||
@@ -223,7 +224,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_responseExposesPagingFields() throws Exception {
|
void search_responseExposesPagingFields() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
@@ -268,7 +269,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_passesPageRequestToService() throws Exception {
|
void search_passesPageRequestToService() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
|
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
|
||||||
@@ -276,7 +277,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
|
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
|
||||||
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
|
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
|
||||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), captor.capture());
|
verify(documentService).searchDocuments(any(), any(), any(), captor.capture());
|
||||||
org.springframework.data.domain.Pageable pageable = captor.getValue();
|
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.getPageNumber()).isEqualTo(2);
|
||||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
|
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
|
||||||
@@ -297,6 +298,13 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void createDocument_returns403_forReaderOnly() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createDocument_returns200_whenHasWritePermission() throws Exception {
|
void createDocument_returns200_whenHasWritePermission() throws Exception {
|
||||||
@@ -394,6 +402,7 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
.delete("/api/documents/" + id).with(csrf()))
|
.delete("/api/documents/" + id).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
@@ -414,6 +423,13 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void quickUpload_returns403_forReaderOnly() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void quickUpload_returns200_withValidPdfFile() throws Exception {
|
void quickUpload_returns200_withValidPdfFile() throws Exception {
|
||||||
@@ -1194,7 +1210,7 @@ class DocumentControllerTest {
|
|||||||
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
|
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean()))
|
when(documentService.findIdsForFilter(any()))
|
||||||
.thenReturn(List.of(id));
|
.thenReturn(List.of(id));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/ids"))
|
mockMvc.perform(get("/api/documents/ids"))
|
||||||
@@ -1207,13 +1223,33 @@ class DocumentControllerTest {
|
|||||||
void getDocumentIds_passesSenderIdParamToService() throws Exception {
|
void getDocumentIds_passesSenderIdParamToService() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
UUID senderId = UUID.randomUUID();
|
UUID senderId = UUID.randomUUID();
|
||||||
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any(), anyBoolean()))
|
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
when(documentService.findIdsForFilter(any()))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
|
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any(), anyBoolean());
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1223,7 +1259,7 @@ class DocumentControllerTest {
|
|||||||
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
|
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
|
||||||
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
|
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
|
||||||
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
|
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
|
||||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean()))
|
when(documentService.findIdsForFilter(any()))
|
||||||
.thenReturn(tooMany);
|
.thenReturn(tooMany);
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/ids"))
|
mockMvc.perform(get("/api/documents/ids"))
|
||||||
@@ -1388,16 +1424,16 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void density_emitsPrivateCacheControlHeader() throws Exception {
|
void density_isNeverBrowserCached() throws Exception {
|
||||||
when(documentService.getDensity(any())).thenReturn(
|
when(documentService.getDensity(any())).thenReturn(
|
||||||
new DocumentDensityResult(List.of(), null, null));
|
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"))
|
mockMvc.perform(get("/api/documents/density"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(header().string("Cache-Control",
|
.andExpect(header().string("Cache-Control",
|
||||||
org.hamcrest.Matchers.containsString("max-age=300")))
|
"no-cache, no-store, max-age=0, must-revalidate"));
|
||||||
.andExpect(header().string("Cache-Control",
|
|
||||||
org.hamcrest.Matchers.containsString("private")));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import java.util.Set;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.assertj.core.api.Assertions.assertThatCode;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -122,14 +123,36 @@ class DocumentLazyLoadingTest {
|
|||||||
savedDocument("SrDoc", "sr_doc.pdf", sender, Set.of(receiver), Set.of(tag));
|
savedDocument("SrDoc", "sr_doc.pdf", sender, Set.of(receiver), Set.of(tag));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.RECEIVER, "asc", null, false, PageRequest.of(0, 20));
|
DocumentSort.RECEIVER, "asc", PageRequest.of(0, 20));
|
||||||
assertThat(result.totalElements()).isGreaterThan(0);
|
assertThat(result.totalElements()).isGreaterThan(0);
|
||||||
assertThatCode(() ->
|
assertThatCode(() ->
|
||||||
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
|
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
|
||||||
.doesNotThrowAnyException();
|
.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
|
@Test
|
||||||
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
|
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
|
||||||
Person sender = savedPerson("Hans", "SsSender");
|
Person sender = savedPerson("Hans", "SsSender");
|
||||||
@@ -137,8 +160,8 @@ class DocumentLazyLoadingTest {
|
|||||||
savedDocument("SsDoc", "ss_doc.pdf", sender, Set.of(), Set.of(tag));
|
savedDocument("SsDoc", "ss_doc.pdf", sender, Set.of(), Set.of(tag));
|
||||||
|
|
||||||
assertThatCode(() -> documentService.searchDocuments(
|
assertThatCode(() -> documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.SENDER, "asc", null, false, PageRequest.of(0, 20)))
|
DocumentSort.SENDER, "asc", PageRequest.of(0, 20)))
|
||||||
.doesNotThrowAnyException();
|
.doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import java.util.HashSet;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,8 +56,8 @@ class DocumentListItemIntegrationTest {
|
|||||||
.build());
|
.build());
|
||||||
|
|
||||||
assertThatCode(() -> documentService.searchDocuments(
|
assertThatCode(() -> documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)))
|
DocumentSort.DATE, "DESC", PageRequest.of(0, 50)))
|
||||||
.doesNotThrowAnyException();
|
.doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +71,8 @@ class DocumentListItemIntegrationTest {
|
|||||||
.build());
|
.build());
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||||
|
|
||||||
assertThat(result.totalElements()).isGreaterThan(0);
|
assertThat(result.totalElements()).isGreaterThan(0);
|
||||||
DocumentListItem item = result.items().get(0);
|
DocumentListItem item = result.items().get(0);
|
||||||
@@ -91,8 +92,8 @@ class DocumentListItemIntegrationTest {
|
|||||||
.build());
|
.build());
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||||
|
|
||||||
DocumentListItem item = result.items().stream()
|
DocumentListItem item = result.items().stream()
|
||||||
.filter(i -> i.title().equals("Range Brief")).findFirst().orElseThrow();
|
.filter(i -> i.title().equals("Range Brief")).findFirst().orElseThrow();
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ import java.util.Optional;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
@DataJpaTest
|
@DataJpaTest
|
||||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
@@ -259,67 +262,6 @@ class DocumentRepositoryTest {
|
|||||||
assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
|
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 ────────────────────────────────────────────────
|
// ─── findSegmentationQueue ────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -612,6 +554,48 @@ class DocumentRepositoryTest {
|
|||||||
.isLessThanOrEqualTo(5);
|
.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 ─────────────────────────────────────────────────────
|
// ─── seeding helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Document uploaded(String title) {
|
private Document uploaded(String title) {
|
||||||
@@ -640,4 +624,88 @@ class DocumentRepositoryTest {
|
|||||||
.reviewed(reviewed)
|
.reviewed(reviewed)
|
||||||
.build();
|
.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import java.time.LocalDate;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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
|
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
|
||||||
@@ -61,8 +62,8 @@ class DocumentSearchPagedIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
|
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(50);
|
assertThat(result.items()).hasSize(50);
|
||||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||||
@@ -74,8 +75,8 @@ class DocumentSearchPagedIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void search_lastPartialPage_returnsRemainingItems() {
|
void search_lastPartialPage_returnsRemainingItems() {
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(2, 50));
|
DocumentSort.DATE, "DESC", PageRequest.of(2, 50));
|
||||||
|
|
||||||
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
|
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
|
||||||
assertThat(result.items()).hasSize(20);
|
assertThat(result.items()).hasSize(20);
|
||||||
@@ -86,8 +87,8 @@ class DocumentSearchPagedIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
|
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(99, 50));
|
DocumentSort.DATE, "DESC", PageRequest.of(99, 50));
|
||||||
|
|
||||||
assertThat(result.items()).isEmpty();
|
assertThat(result.items()).isEmpty();
|
||||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||||
@@ -99,8 +100,8 @@ class DocumentSearchPagedIntegrationTest {
|
|||||||
// comment in DocumentService). Proves that the in-memory slice path
|
// comment in DocumentService). Proves that the in-memory slice path
|
||||||
// returns the correct total from a real repository fetch.
|
// returns the correct total from a real repository fetch.
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.SENDER, "asc", null, false, PageRequest.of(1, 50));
|
DocumentSort.SENDER, "asc", PageRequest.of(1, 50));
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(50);
|
assertThat(result.items()).hasSize(50);
|
||||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||||
@@ -125,8 +126,8 @@ class DocumentSearchPagedIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||||
|
|
||||||
// Global undated count is the full undated total, independent of page size.
|
// Global undated count is the full undated total, independent of page size.
|
||||||
assertThat(result.undatedCount()).isEqualTo(undatedTotal);
|
assertThat(result.undatedCount()).isEqualTo(undatedTotal);
|
||||||
@@ -153,11 +154,11 @@ class DocumentSearchPagedIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DocumentSearchResult unfiltered = documentService.searchDocuments(
|
DocumentSearchResult unfiltered = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||||
DocumentSearchResult undatedOnly = documentService.searchDocuments(
|
DocumentSearchResult undatedOnly = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters().withUndated(true),
|
||||||
DocumentSort.DATE, "DESC", null, true, PageRequest.of(0, 50));
|
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||||
|
|
||||||
assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
|
assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
|
||||||
assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
|
assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
|
||||||
@@ -178,9 +179,9 @@ class DocumentSearchPagedIntegrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
|
new SearchFilters(null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
|
||||||
null, null, null, null, null,
|
null, null, null, null, null, null, false),
|
||||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||||
|
|
||||||
assertThat(result.undatedCount()).isZero();
|
assertThat(result.undatedCount()).isZero();
|
||||||
}
|
}
|
||||||
@@ -188,11 +189,11 @@ class DocumentSearchPagedIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
void search_differentPagesReturnDisjointSlices() {
|
void search_differentPagesReturnDisjointSlices() {
|
||||||
DocumentSearchResult page0 = documentService.searchDocuments(
|
DocumentSearchResult page0 = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||||
DocumentSearchResult page1 = documentService.searchDocuments(
|
DocumentSearchResult page1 = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null,
|
noFilters(),
|
||||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(1, 50));
|
DocumentSort.DATE, "DESC", PageRequest.of(1, 50));
|
||||||
|
|
||||||
// No document id should appear on both pages — slicing must be exclusive.
|
// No document id should appear on both pages — slicing must be exclusive.
|
||||||
var idsOnPage0 = page0.items().stream()
|
var idsOnPage0 = page0.items().stream()
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ class DocumentServiceSortTest {
|
|||||||
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, false, PAGE);
|
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||||
|
DocumentSort.DATE, "DESC", PAGE);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
|
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
|
||||||
@@ -80,11 +81,12 @@ class DocumentServiceSortTest {
|
|||||||
UUID id1 = UUID.randomUUID();
|
UUID id1 = UUID.randomUUID();
|
||||||
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
|
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAllById(any()))
|
when(documentRepository.findByIdIn(any()))
|
||||||
.thenReturn(List.of(doc(id1)));
|
.thenReturn(List.of(doc(id1)));
|
||||||
|
|
||||||
documentService.searchDocuments(
|
documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE);
|
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||||
|
DocumentSort.RELEVANCE, null, PAGE);
|
||||||
|
|
||||||
verify(documentRepository).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
verify(documentRepository).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
||||||
verify(documentRepository, never()).findAllMatchingIdsByFts(anyString());
|
verify(documentRepository, never()).findAllMatchingIdsByFts(anyString());
|
||||||
@@ -99,10 +101,11 @@ class DocumentServiceSortTest {
|
|||||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE);
|
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||||
|
DocumentSort.RELEVANCE, null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
@@ -116,10 +119,11 @@ class DocumentServiceSortTest {
|
|||||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, null, null, null, false, PAGE);
|
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||||
|
null, null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
@@ -132,8 +136,8 @@ class DocumentServiceSortTest {
|
|||||||
Pageable hugePage = org.springframework.data.domain.PageRequest.of(Integer.MAX_VALUE / 10 + 1, 10);
|
Pageable hugePage = org.springframework.data.domain.PageRequest.of(Integer.MAX_VALUE / 10 + 1, 10);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null,
|
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||||
DocumentSort.RELEVANCE, null, null, false, hugePage);
|
DocumentSort.RELEVANCE, null, hugePage);
|
||||||
|
|
||||||
assertThat(result.items()).isEmpty();
|
assertThat(result.items()).isEmpty();
|
||||||
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
||||||
@@ -149,11 +153,11 @@ class DocumentServiceSortTest {
|
|||||||
List<Object[]> ftsRows = new ArrayList<>();
|
List<Object[]> ftsRows = new ArrayList<>();
|
||||||
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
|
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
|
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId)));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null,
|
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||||
DocumentSort.RELEVANCE, null, null, false, PAGE);
|
DocumentSort.RELEVANCE, null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(1);
|
assertThat(result.items()).hasSize(1);
|
||||||
assertThat(result.items().get(0).id()).isEqualTo(uuidId);
|
assertThat(result.items().get(0).id()).isEqualTo(uuidId);
|
||||||
@@ -173,7 +177,8 @@ class DocumentServiceSortTest {
|
|||||||
// sender filter is active → triggers in-memory path, not findFtsPageRaw
|
// sender filter is active → triggers in-memory path, not findFtsPageRaw
|
||||||
LocalDate from = LocalDate.of(1900, 1, 1);
|
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||||
documentService.searchDocuments(
|
documentService.searchDocuments(
|
||||||
"Brief", from, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, PAGE);
|
new SearchFilters("Brief", from, null, null, null, null, null, null, null, false),
|
||||||
|
DocumentSort.RELEVANCE, null, PAGE);
|
||||||
|
|
||||||
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
||||||
verify(documentRepository).findAllMatchingIdsByFts("Brief");
|
verify(documentRepository).findAllMatchingIdsByFts("Brief");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||||
@@ -20,6 +21,7 @@ import org.raddatz.familienarchiv.document.MatchOffset;
|
|||||||
import org.raddatz.familienarchiv.document.SearchMatchData;
|
import org.raddatz.familienarchiv.document.SearchMatchData;
|
||||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
@@ -28,6 +30,7 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
|
|||||||
import org.raddatz.familienarchiv.filestorage.FileService;
|
import org.raddatz.familienarchiv.filestorage.FileService;
|
||||||
import org.raddatz.familienarchiv.tag.TagService;
|
import org.raddatz.familienarchiv.tag.TagService;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -45,6 +48,7 @@ import java.util.Set;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
@@ -72,6 +76,10 @@ class DocumentServiceTest {
|
|||||||
@Mock AuditLogQueryService auditLogQueryService;
|
@Mock AuditLogQueryService auditLogQueryService;
|
||||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
@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;
|
@InjectMocks DocumentService documentService;
|
||||||
|
|
||||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||||
@@ -81,7 +89,7 @@ class DocumentServiceTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(documentRepository.existsById(id)).thenReturn(true);
|
when(documentRepository.existsById(id)).thenReturn(true);
|
||||||
|
|
||||||
documentService.deleteDocument(id);
|
documentService.deleteDocument(id, UUID.randomUUID());
|
||||||
|
|
||||||
verify(documentRepository).deleteById(id);
|
verify(documentRepository).deleteById(id);
|
||||||
}
|
}
|
||||||
@@ -91,7 +99,7 @@ class DocumentServiceTest {
|
|||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(documentRepository.existsById(id)).thenReturn(false);
|
when(documentRepository.existsById(id)).thenReturn(false);
|
||||||
|
|
||||||
assertThatThrownBy(() -> documentService.deleteDocument(id))
|
assertThatThrownBy(() -> documentService.deleteDocument(id, UUID.randomUUID()))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.hasMessageContaining(id.toString());
|
.hasMessageContaining(id.toString());
|
||||||
verify(documentRepository, never()).deleteById(any());
|
verify(documentRepository, never()).deleteById(any());
|
||||||
@@ -118,6 +126,37 @@ class DocumentServiceTest {
|
|||||||
assertThat(documentService.getDocumentById(id)).isEqualTo(doc);
|
assertThat(documentService.getDocumentById(id)).isEqualTo(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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() {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDocumentDetail_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();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── updateDocument ───────────────────────────────────────────────────────
|
// ─── updateDocument ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -171,10 +210,12 @@ class DocumentServiceTest {
|
|||||||
// Editing a doc (e.g. fixing a location typo) without touching the precision
|
// 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
|
// 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.
|
// 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();
|
UUID id = UUID.randomUUID();
|
||||||
Document doc = Document.builder()
|
Document doc = Document.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.metaDatePrecision(DatePrecision.MONTH)
|
.metaDatePrecision(DatePrecision.RANGE)
|
||||||
.metaDateEnd(LocalDate.of(1916, 6, 30))
|
.metaDateEnd(LocalDate.of(1916, 6, 30))
|
||||||
.metaDateRaw("Juni 1916")
|
.metaDateRaw("Juni 1916")
|
||||||
.receivers(new HashSet<>())
|
.receivers(new HashSet<>())
|
||||||
@@ -188,11 +229,329 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
documentService.updateDocument(id, dto, null, null);
|
documentService.updateDocument(id, dto, null, null);
|
||||||
|
|
||||||
assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.MONTH);
|
assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
|
||||||
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1916, 6, 30));
|
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1916, 6, 30));
|
||||||
assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916");
|
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 ───────────────────────────────────────────────────
|
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -338,6 +697,59 @@ class DocumentServiceTest {
|
|||||||
verify(documentVersionService).recordVersion(any(Document.class));
|
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 ───────────────────────────────────────────────────
|
// ─── thumbnail dispatch ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -985,53 +1397,6 @@ class DocumentServiceTest {
|
|||||||
.isEqualTo("19650332_Mueller_Hans");
|
.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 ───────────────────────────────
|
// ─── updateDocumentTags — empty tag in list ───────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1410,8 +1775,9 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
documentService.searchDocuments(
|
||||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
|
noFilters(),
|
||||||
|
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(1, 50));
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
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));
|
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||||
@@ -1423,8 +1789,9 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
documentService.searchDocuments(
|
||||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(3, 25));
|
noFilters(),
|
||||||
|
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(3, 25));
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||||
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
|
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
|
||||||
@@ -1439,8 +1806,9 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
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));
|
.thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 50));
|
noFilters(),
|
||||||
|
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(0, 50));
|
||||||
|
|
||||||
assertThat(result.totalElements()).isEqualTo(120L);
|
assertThat(result.totalElements()).isEqualTo(120L);
|
||||||
assertThat(result.pageNumber()).isZero();
|
assertThat(result.pageNumber()).isZero();
|
||||||
@@ -1455,8 +1823,9 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
documentService.searchDocuments(
|
||||||
DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
|
noFilters(),
|
||||||
|
DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(0, 5));
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||||
Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate");
|
Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate");
|
||||||
@@ -1478,8 +1847,9 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
documentService.searchDocuments(
|
||||||
DocumentSort.DATE, "ASC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
|
noFilters(),
|
||||||
|
DocumentSort.DATE, "ASC", org.springframework.data.domain.PageRequest.of(0, 5));
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||||
Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate");
|
Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate");
|
||||||
@@ -1499,8 +1869,9 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
documentService.searchDocuments(
|
||||||
DocumentSort.UPDATED_AT, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
|
noFilters(),
|
||||||
|
DocumentSort.UPDATED_AT, "DESC", org.springframework.data.domain.PageRequest.of(0, 5));
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
|
||||||
assertThat(captor.getValue().getSort())
|
assertThat(captor.getValue().getSort())
|
||||||
@@ -1523,8 +1894,9 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
.thenReturn(all);
|
.thenReturn(all);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
|
noFilters(),
|
||||||
|
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", org.springframework.data.domain.PageRequest.of(1, 50));
|
||||||
|
|
||||||
assertThat(result.totalElements()).isEqualTo(120L);
|
assertThat(result.totalElements()).isEqualTo(120L);
|
||||||
assertThat(result.pageNumber()).isEqualTo(1);
|
assertThat(result.pageNumber()).isEqualTo(1);
|
||||||
@@ -1547,8 +1919,9 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
.thenReturn(all);
|
.thenReturn(all);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(10, 50));
|
noFilters(),
|
||||||
|
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", org.springframework.data.domain.PageRequest.of(10, 50));
|
||||||
|
|
||||||
assertThat(result.items()).isEmpty();
|
assertThat(result.items()).isEmpty();
|
||||||
assertThat(result.totalElements()).isEqualTo(30L);
|
assertThat(result.totalElements()).isEqualTo(30L);
|
||||||
@@ -1561,7 +1934,8 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, false, UNPAGED);
|
documentService.searchDocuments(
|
||||||
|
new SearchFilters(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, false), null, null, UNPAGED);
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||||
}
|
}
|
||||||
@@ -1571,7 +1945,8 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED);
|
documentService.searchDocuments(
|
||||||
|
noFilters(), null, null, UNPAGED);
|
||||||
|
|
||||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
|
||||||
}
|
}
|
||||||
@@ -1607,35 +1982,6 @@ class DocumentServiceTest {
|
|||||||
.isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
|
.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 ─────
|
// ─── searchDocuments — SENDER sort includes documents with null sender ─────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1649,7 +1995,8 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(withSender, noSender));
|
.thenReturn(List.of(withSender, noSender));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
|
noFilters(),
|
||||||
|
DocumentSort.SENDER, "asc", UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
|
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
|
||||||
@@ -1669,7 +2016,8 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(noReceivers, withReceiver));
|
.thenReturn(List.of(noReceivers, withReceiver));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, false, UNPAGED);
|
noFilters(),
|
||||||
|
DocumentSort.RECEIVER, "asc", UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||||
.containsExactly("Has Receiver", "No Receivers");
|
.containsExactly("Has Receiver", "No Receivers");
|
||||||
@@ -1702,7 +2050,8 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
|
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
|
noFilters(),
|
||||||
|
DocumentSort.SENDER, "asc", UNPAGED);
|
||||||
|
|
||||||
// Bob's group precedes Anna's group (ASC by sender). The sort is stable, so
|
// 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;
|
// within each group the input order is preserved (undatedBob, datedBob for Bob;
|
||||||
@@ -1733,7 +2082,8 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
|
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "desc", null, false, UNPAGED);
|
noFilters(),
|
||||||
|
DocumentSort.SENDER, "desc", UNPAGED);
|
||||||
|
|
||||||
// Anna's group precedes Bob's (DESC by sender); undated stays inside its group.
|
// Anna's group precedes Bob's (DESC by sender); undated stays inside its group.
|
||||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||||
@@ -1756,7 +2106,8 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(undatedFromAlice));
|
.thenReturn(List.of(undatedFromAlice));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, true, UNPAGED);
|
noFilters().withUndated(true),
|
||||||
|
DocumentSort.SENDER, "asc", UNPAGED);
|
||||||
|
|
||||||
// The in-memory path queried via a Specification (built by buildSearchSpec with
|
// The in-memory path queried via a Specification (built by buildSearchSpec with
|
||||||
// undatedOnly(true)) rather than skipping straight to a sorted findAll.
|
// undatedOnly(true)) rather than skipping straight to a sorted findAll.
|
||||||
@@ -1772,8 +2123,9 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
documentService.searchDocuments("brief", null, null, null, null, null, null, null,
|
documentService.searchDocuments(
|
||||||
DocumentSort.RELEVANCE, null, null, true, UNPAGED);
|
new SearchFilters("brief", null, null, null, null, null, null, null, null, true),
|
||||||
|
DocumentSort.RELEVANCE, null, UNPAGED);
|
||||||
|
|
||||||
// The FTS-id path (buildSearchSpec) ran; the raw-page SQL shortcut did not.
|
// The FTS-id path (buildSearchSpec) ran; the raw-page SQL shortcut did not.
|
||||||
verify(documentRepository).findAllMatchingIdsByFts("brief");
|
verify(documentRepository).findAllMatchingIdsByFts("brief");
|
||||||
@@ -1796,7 +2148,8 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(docNullName, docSmith));
|
.thenReturn(List.of(docNullName, docSmith));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED);
|
noFilters(),
|
||||||
|
DocumentSort.SENDER, "asc", UNPAGED);
|
||||||
|
|
||||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||||
@@ -1815,11 +2168,12 @@ class DocumentServiceTest {
|
|||||||
List<Object[]> ftsRows = new java.util.ArrayList<>();
|
List<Object[]> ftsRows = new java.util.ArrayList<>();
|
||||||
ftsRows.add(new Object[]{docId, 0.5d, 1L});
|
ftsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, UNPAGED);
|
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||||
|
DocumentSort.RELEVANCE, null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(1);
|
assertThat(result.items()).hasSize(1);
|
||||||
SearchMatchData md = result.items().get(0).matchData();
|
SearchMatchData md = result.items().get(0).matchData();
|
||||||
@@ -1833,7 +2187,8 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(new PageImpl<>(List.of()));
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, null, null, null, false, UNPAGED);
|
noFilters(),
|
||||||
|
null, null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).isEmpty();
|
assertThat(result.items()).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -1849,11 +2204,12 @@ class DocumentServiceTest {
|
|||||||
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
|
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
|
||||||
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
|
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
|
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
|
||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, false, UNPAGED);
|
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||||
|
DocumentSort.RELEVANCE, null, UNPAGED);
|
||||||
|
|
||||||
SearchMatchData md = result.items().get(0).matchData();
|
SearchMatchData md = result.items().get(0).matchData();
|
||||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||||
@@ -2370,7 +2726,7 @@ class DocumentServiceTest {
|
|||||||
.thenReturn(List.of(d1, d2));
|
.thenReturn(List.of(d1, d2));
|
||||||
|
|
||||||
List<UUID> result = documentService.findIdsForFilter(
|
List<UUID> result = documentService.findIdsForFilter(
|
||||||
null, null, null, null, null, null, null, null, null, false);
|
noFilters());
|
||||||
|
|
||||||
assertThat(result).containsExactly(d1.getId(), d2.getId());
|
assertThat(result).containsExactly(d1.getId(), d2.getId());
|
||||||
}
|
}
|
||||||
@@ -2385,7 +2741,7 @@ class DocumentServiceTest {
|
|||||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||||
|
|
||||||
documentService.findIdsForFilter(
|
documentService.findIdsForFilter(
|
||||||
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR, false);
|
new SearchFilters(null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR, false));
|
||||||
|
|
||||||
// Spec built without throwing → OR branch was exercised. Coverage gain
|
// Spec built without throwing → OR branch was exercised. Coverage gain
|
||||||
// is in not-throwing on the OR-specific code path; the actual SQL is
|
// is in not-throwing on the OR-specific code path; the actual SQL is
|
||||||
@@ -2398,7 +2754,7 @@ class DocumentServiceTest {
|
|||||||
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
|
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
|
||||||
|
|
||||||
List<UUID> result = documentService.findIdsForFilter(
|
List<UUID> result = documentService.findIdsForFilter(
|
||||||
"xyz", null, null, null, null, null, null, null, null, false);
|
new SearchFilters("xyz", null, null, null, null, null, null, null, null, false));
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
assertThat(result).isEmpty();
|
||||||
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.DynamicTest;
|
import org.junit.jupiter.api.DynamicTest;
|
||||||
import org.junit.jupiter.api.TestFactory;
|
import org.junit.jupiter.api.TestFactory;
|
||||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,15 @@ class AnnotationControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void createAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(ANNOTATION_JSON))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception {
|
void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception {
|
||||||
@@ -190,6 +199,15 @@ class AnnotationControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void patchAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(PATCH_JSON))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns200_withWriteAllPermission() throws Exception {
|
void patchAnnotation_returns200_withWriteAllPermission() throws Exception {
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ class CommentControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception {
|
void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception {
|
||||||
@@ -142,6 +151,16 @@ class CommentControllerTest {
|
|||||||
.andExpect(status().isUnauthorized());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
|
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
|
||||||
@@ -181,6 +200,14 @@ class CommentControllerTest {
|
|||||||
.andExpect(status().isUnauthorized());
|
.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
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void editComment_returns200_whenHasPermission() throws Exception {
|
void editComment_returns200_whenHasPermission() throws Exception {
|
||||||
|
|||||||
@@ -159,6 +159,15 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void createBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(CREATE_JSON))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception {
|
void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception {
|
||||||
@@ -233,6 +242,15 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void updateBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(UPDATE_JSON))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception {
|
void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception {
|
||||||
@@ -363,6 +381,15 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void reorderBlocks_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(REORDER_JSON))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
||||||
@@ -440,6 +467,14 @@ class TranscriptionBlockControllerTest {
|
|||||||
.andExpect(jsonPath("$.reviewed").value(true));
|
.andExpect(jsonPath("$.reviewed").value(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void reviewBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
|
||||||
|
DOC_ID, BLOCK_ID).with(csrf()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PUT .../review-all ───────────────────────────────────────────────────
|
// ─── PUT .../review-all ───────────────────────────────────────────────────
|
||||||
|
|
||||||
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
|
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import org.raddatz.familienarchiv.document.annotation.DocumentAnnotation;
|
|||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.document.transcription.PersonMention;
|
import org.raddatz.familienarchiv.document.transcription.PersonMention;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
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.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
@@ -30,6 +32,7 @@ class TranscriptionBlockMentionsRepositoryTest {
|
|||||||
@Autowired TranscriptionBlockRepository blockRepository;
|
@Autowired TranscriptionBlockRepository blockRepository;
|
||||||
@Autowired DocumentRepository documentRepository;
|
@Autowired DocumentRepository documentRepository;
|
||||||
@Autowired AnnotationRepository annotationRepository;
|
@Autowired AnnotationRepository annotationRepository;
|
||||||
|
@Autowired PersonRepository personRepository;
|
||||||
@Autowired EntityManager em;
|
@Autowired EntityManager em;
|
||||||
|
|
||||||
private UUID documentId;
|
private UUID documentId;
|
||||||
@@ -55,8 +58,9 @@ class TranscriptionBlockMentionsRepositoryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mentionedPersons_roundTripsTwoEntries() {
|
void mentionedPersons_roundTripsTwoEntries() {
|
||||||
UUID auguste = UUID.randomUUID();
|
// person_id is a real FK since V71 — the mentioned persons must exist.
|
||||||
UUID hermann = UUID.randomUUID();
|
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();
|
||||||
|
|
||||||
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
.annotationId(annotationId)
|
.annotationId(annotationId)
|
||||||
@@ -97,8 +101,9 @@ class TranscriptionBlockMentionsRepositoryTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
|
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
|
||||||
UUID augusteId = UUID.randomUUID();
|
// person_id is a real FK since V71 — the mentioned persons must exist.
|
||||||
UUID hermannId = UUID.randomUUID();
|
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();
|
||||||
|
|
||||||
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||||
.annotationId(annotationId).documentId(documentId)
|
.annotationId(annotationId).documentId(documentId)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package org.raddatz.familienarchiv.document.transcription;
|
||||||
|
|
||||||
|
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 TranscriptionBlockQueryServiceTest {
|
||||||
|
|
||||||
|
@Mock TranscriptionBlockRepository blockRepository;
|
||||||
|
@InjectMocks TranscriptionBlockQueryService queryService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasBlocks_returns_true_when_a_block_exists() {
|
||||||
|
UUID documentId = UUID.randomUUID();
|
||||||
|
when(blockRepository.existsByDocumentId(documentId)).thenReturn(true);
|
||||||
|
|
||||||
|
assertThat(queryService.hasBlocks(documentId)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasBlocks_returns_false_when_no_block_exists() {
|
||||||
|
UUID documentId = UUID.randomUUID();
|
||||||
|
when(blockRepository.existsByDocumentId(documentId)).thenReturn(false);
|
||||||
|
|
||||||
|
assertThat(queryService.hasBlocks(documentId)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,4 +102,22 @@ class TranscriptionBlockRepositoryIntegrationTest {
|
|||||||
assertThat(byDoc).containsEntry(DOC_A, 100);
|
assertThat(byDoc).containsEntry(DOC_A, 100);
|
||||||
assertThat(byDoc).containsEntry(DOC_B, 0);
|
assertThat(byDoc).containsEntry(DOC_B, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Sql(statements = {
|
||||||
|
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')",
|
||||||
|
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 1, 0, 0, 1, 1, '#fff')",
|
||||||
|
"INSERT INTO transcription_blocks (annotation_id, document_id, sort_order, reviewed) VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 0, false)"
|
||||||
|
})
|
||||||
|
void existsByDocumentId_returns_true_when_document_has_a_block() {
|
||||||
|
assertThat(repository.existsByDocumentId(DOC_A)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Sql(statements = {
|
||||||
|
"INSERT INTO documents (id, title, original_filename, status) VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Doc A', 'a.pdf', 'PLACEHOLDER')"
|
||||||
|
})
|
||||||
|
void existsByDocumentId_returns_false_when_document_has_no_blocks() {
|
||||||
|
assertThat(repository.existsByDocumentId(DOC_A)).isFalse();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
package org.raddatz.familienarchiv.exception;
|
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 io.sentry.Sentry;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.MockedStatic;
|
import org.mockito.MockedStatic;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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 org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -30,4 +37,108 @@ class GlobalExceptionHandlerTest {
|
|||||||
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,13 @@ package org.raddatz.familienarchiv.geschichte;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
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.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||||
|
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
@@ -21,22 +19,25 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.hamcrest.CoreMatchers.nullValue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
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.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
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.patch;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
|
|
||||||
@WebMvcTest(GeschichteController.class)
|
@WebMvcTest(GeschichteController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -47,11 +48,9 @@ class GeschichteControllerTest {
|
|||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
@MockitoBean
|
@MockitoBean GeschichteService geschichteService;
|
||||||
GeschichteService geschichteService;
|
@MockitoBean JourneyItemService journeyItemService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
@MockitoBean
|
|
||||||
CustomUserDetailsService customUserDetailsService;
|
|
||||||
|
|
||||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,7 +64,7 @@ class GeschichteControllerTest {
|
|||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void list_returns200_forReader() throws Exception {
|
void list_returns200_forReader() throws Exception {
|
||||||
when(geschichteService.list(any(), any(), any(), anyInt()))
|
when(geschichteService.list(any(), any(), any(), anyInt()))
|
||||||
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
|
.thenReturn(List.of(summaryStub("Story A")));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/geschichten"))
|
mockMvc.perform(get("/api/geschichten"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -101,13 +100,50 @@ class GeschichteControllerTest {
|
|||||||
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
|
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} ───────────────────────────────────────────
|
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getById_returns200_whenFound() throws Exception {
|
void getById_returns200_whenFound() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
|
when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -119,7 +155,7 @@ class GeschichteControllerTest {
|
|||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(geschichteService.getById(id))
|
when(geschichteService.getView(id))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||||
@@ -151,7 +187,7 @@ class GeschichteControllerTest {
|
|||||||
void create_returns201_withBlogWrite() throws Exception {
|
void create_returns201_withBlogWrite() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
|
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
|
||||||
.thenReturn(draft(id, "New"));
|
.thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
|
||||||
|
|
||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setTitle("New");
|
dto.setTitle("New");
|
||||||
@@ -179,7 +215,7 @@ class GeschichteControllerTest {
|
|||||||
void update_returns200_withBlogWrite() throws Exception {
|
void update_returns200_withBlogWrite() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||||
.thenReturn(published(id, "Updated"));
|
.thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -208,31 +244,202 @@ class GeschichteControllerTest {
|
|||||||
verify(geschichteService).delete(id);
|
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 ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Geschichte published(UUID id, String title) {
|
private JourneyItemView itemViewStub(UUID id, int position, String note) {
|
||||||
return Geschichte.builder()
|
return new JourneyItemView(id, position, null, note);
|
||||||
.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 Geschichte draft(UUID id, String title) {
|
private GeschichteView viewStub(UUID id, String title) {
|
||||||
return Geschichte.builder()
|
return viewStub(id, title, GeschichteStatus.PUBLISHED);
|
||||||
.id(id)
|
}
|
||||||
.title(title)
|
|
||||||
.status(GeschichteStatus.DRAFT)
|
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
|
||||||
.createdAt(LocalDateTime.now())
|
return new GeschichteView(id, title, "<p>x</p>",
|
||||||
.updatedAt(LocalDateTime.now())
|
status, GeschichteType.STORY,
|
||||||
.persons(new HashSet<>())
|
null, new HashSet<>(), List.of(),
|
||||||
.documents(new HashSet<>())
|
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
|
||||||
.build();
|
}
|
||||||
|
|
||||||
|
/** 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; }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,12 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
|||||||
import org.raddatz.familienarchiv.user.AppUser;
|
import org.raddatz.familienarchiv.user.AppUser;
|
||||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
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.person.Person;
|
||||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||||
|
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -39,6 +42,7 @@ class GeschichteServiceIntegrationTest {
|
|||||||
S3Client s3Client;
|
S3Client s3Client;
|
||||||
|
|
||||||
@Autowired GeschichteService geschichteService;
|
@Autowired GeschichteService geschichteService;
|
||||||
|
@Autowired JourneyItemService journeyItemService;
|
||||||
@Autowired GeschichteRepository geschichteRepository;
|
@Autowired GeschichteRepository geschichteRepository;
|
||||||
@Autowired PersonRepository personRepository;
|
@Autowired PersonRepository personRepository;
|
||||||
@Autowired AppUserRepository appUserRepository;
|
@Autowired AppUserRepository appUserRepository;
|
||||||
@@ -76,11 +80,11 @@ class GeschichteServiceIntegrationTest {
|
|||||||
+ "<script>alert('xss')</script>");
|
+ "<script>alert('xss')</script>");
|
||||||
dto.setPersonIds(List.of(franz.getId()));
|
dto.setPersonIds(List.of(franz.getId()));
|
||||||
|
|
||||||
Geschichte created = geschichteService.create(dto);
|
GeschichteView created = geschichteService.create(dto);
|
||||||
|
|
||||||
assertThat(created.getId()).isNotNull();
|
assertThat(created.id()).isNotNull();
|
||||||
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||||
assertThat(created.getBody())
|
assertThat(created.body())
|
||||||
.contains("<strong>jeden Sonntag</strong>")
|
.contains("<strong>jeden Sonntag</strong>")
|
||||||
.doesNotContain("<script>");
|
.doesNotContain("<script>");
|
||||||
|
|
||||||
@@ -89,7 +93,7 @@ class GeschichteServiceIntegrationTest {
|
|||||||
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
||||||
|
|
||||||
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
||||||
UUID draftId = created.getId();
|
UUID draftId = created.id();
|
||||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
|
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
|
||||||
.hasMessageContaining("not found");
|
.hasMessageContaining("not found");
|
||||||
|
|
||||||
@@ -97,16 +101,17 @@ class GeschichteServiceIntegrationTest {
|
|||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
|
||||||
publishDto.setStatus(GeschichteStatus.PUBLISHED);
|
publishDto.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
|
GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
|
||||||
assertThat(publishedGesch.getPublishedAt()).isNotNull();
|
assertThat(publishedGesch.publishedAt()).isNotNull();
|
||||||
|
|
||||||
// Reader can now see and fetch it
|
// Reader can now see and fetch it
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
||||||
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
||||||
Geschichte fetched = geschichteService.getById(draftId);
|
Geschichte fetched = geschichteService.getById(draftId);
|
||||||
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
|
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
|
||||||
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
|
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
|
||||||
|
assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId());
|
||||||
|
|
||||||
// Delete as writer; join rows go with it
|
// Delete as writer; join rows go with it
|
||||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||||
@@ -137,17 +142,17 @@ class GeschichteServiceIntegrationTest {
|
|||||||
|
|
||||||
// No filter → all three
|
// No filter → all three
|
||||||
assertThat(geschichteService.list(null, List.of(), null, 50))
|
assertThat(geschichteService.list(null, List.of(), null, 50))
|
||||||
.extracting(Geschichte::getId)
|
.extracting(GeschichteSummary::getId)
|
||||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||||
|
|
||||||
// Single filter (Anna) → all three
|
// Single filter (Anna) → all three
|
||||||
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
||||||
.extracting(Geschichte::getId)
|
.extracting(GeschichteSummary::getId)
|
||||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||||
|
|
||||||
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
|
// 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))
|
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
|
||||||
.extracting(Geschichte::getId)
|
.extracting(GeschichteSummary::getId)
|
||||||
.containsExactly(storyAB);
|
.containsExactly(storyAB);
|
||||||
|
|
||||||
// AND: Bertha AND Carl → none (no story has both)
|
// AND: Bertha AND Carl → none (no story has both)
|
||||||
@@ -174,7 +179,7 @@ class GeschichteServiceIntegrationTest {
|
|||||||
geschichteService.create(dto);
|
geschichteService.create(dto);
|
||||||
|
|
||||||
authenticateAs(writer2, Permission.BLOG_WRITE);
|
authenticateAs(writer2, Permission.BLOG_WRITE);
|
||||||
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||||
|
|
||||||
assertThat(result).isEmpty();
|
assertThat(result).isEmpty();
|
||||||
}
|
}
|
||||||
@@ -185,7 +190,7 @@ class GeschichteServiceIntegrationTest {
|
|||||||
dto.setBody("<p>body</p>");
|
dto.setBody("<p>body</p>");
|
||||||
dto.setPersonIds(personIds);
|
dto.setPersonIds(personIds);
|
||||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
return geschichteService.create(dto).getId();
|
return geschichteService.create(dto).id();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||||
|
|||||||
@@ -2,31 +2,28 @@ package org.raddatz.familienarchiv.geschichte;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
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.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.person.Person;
|
||||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.user.UserService;
|
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.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -37,7 +34,11 @@ import java.util.stream.Collectors;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
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.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -45,17 +46,13 @@ import static org.mockito.Mockito.when;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class GeschichteServiceTest {
|
class GeschichteServiceTest {
|
||||||
|
|
||||||
@Mock
|
@Mock GeschichteRepository geschichteRepository;
|
||||||
GeschichteRepository geschichteRepository;
|
@Mock PersonService personService;
|
||||||
@Mock
|
@Mock DocumentService documentService;
|
||||||
PersonService personService;
|
@Mock UserService userService;
|
||||||
@Mock
|
@Mock JourneyItemService journeyItemService;
|
||||||
DocumentService documentService;
|
|
||||||
@Mock
|
|
||||||
UserService userService;
|
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks GeschichteService geschichteService;
|
||||||
GeschichteService geschichteService;
|
|
||||||
|
|
||||||
AppUser writer;
|
AppUser writer;
|
||||||
AppUser reader;
|
AppUser reader;
|
||||||
@@ -96,7 +93,8 @@ class GeschichteServiceTest {
|
|||||||
|
|
||||||
Geschichte result = geschichteService.getById(id);
|
Geschichte result = geschichteService.getById(id);
|
||||||
|
|
||||||
assertThat(result).isSameAs(draft);
|
assertThat(result.getId()).isEqualTo(id);
|
||||||
|
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -108,7 +106,8 @@ class GeschichteServiceTest {
|
|||||||
|
|
||||||
Geschichte result = geschichteService.getById(id);
|
Geschichte result = geschichteService.getById(id);
|
||||||
|
|
||||||
assertThat(result).isSameAs(published);
|
assertThat(result.getId()).isEqualTo(id);
|
||||||
|
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -123,83 +122,207 @@ class GeschichteServiceTest {
|
|||||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
.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 ─────────────────────────────────────────────────────────────────
|
// ─── list ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
.thenReturn(List.of());
|
||||||
|
|
||||||
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
|
geschichteService.list(null, List.of(), null, 50);
|
||||||
|
|
||||||
// Status pinning lives inside the Specification; we assert end-to-end behaviour
|
verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
|
||||||
// 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
|
@Test
|
||||||
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
|
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
|
||||||
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);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
UUID personId = UUID.randomUUID();
|
UUID personId = UUID.randomUUID();
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
geschichteService.list(null, List.of(personId), null, 50);
|
geschichteService.list(null, List.of(personId), null, 50);
|
||||||
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
|
void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() {
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
UUID a = UUID.randomUUID();
|
UUID a = UUID.randomUUID();
|
||||||
UUID b = UUID.randomUUID();
|
UUID b = UUID.randomUUID();
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
geschichteService.list(null, List.of(a, b), null, 50);
|
geschichteService.list(null, List.of(a, b), null, 50);
|
||||||
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void list_filters_by_documentId() {
|
void list_passes_documentId_to_repository_as_journey_item_filter() {
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
UUID documentId = UUID.randomUUID();
|
UUID documentId = UUID.randomUUID();
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
geschichteService.list(null, List.of(), documentId, 50);
|
geschichteService.list(null, List.of(), documentId, 50);
|
||||||
|
|
||||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
|
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.
|
||||||
authenticateAs(reader, Permission.READ_ALL);
|
authenticateAs(reader, Permission.READ_ALL);
|
||||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
.thenReturn(List.of());
|
||||||
|
|
||||||
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
|
geschichteService.list(null, List.of(), null, 50);
|
||||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
|
|
||||||
|
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);
|
||||||
|
|
||||||
assertThat(out).hasSizeLessThanOrEqualTo(200);
|
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 ──────────────────────────────────────────────────────────────
|
// ─── create ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -213,11 +336,11 @@ class GeschichteServiceTest {
|
|||||||
dto.setTitle("My Story");
|
dto.setTitle("My Story");
|
||||||
dto.setBody("<p>plain text</p>");
|
dto.setBody("<p>plain text</p>");
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
GeschichteView saved = geschichteService.create(dto);
|
||||||
|
|
||||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||||
assertThat(saved.getPublishedAt()).isNull();
|
assertThat(saved.publishedAt()).isNull();
|
||||||
assertThat(saved.getAuthor()).isSameAs(writer);
|
assertThat(saved.author().id()).isEqualTo(writer.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -231,9 +354,9 @@ class GeschichteServiceTest {
|
|||||||
dto.setTitle("XSS attempt");
|
dto.setTitle("XSS attempt");
|
||||||
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
|
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
GeschichteView saved = geschichteService.create(dto);
|
||||||
|
|
||||||
assertThat(saved.getBody())
|
assertThat(saved.body())
|
||||||
.contains("<p>safe</p>")
|
.contains("<p>safe</p>")
|
||||||
.doesNotContain("<script>")
|
.doesNotContain("<script>")
|
||||||
.doesNotContain("onerror")
|
.doesNotContain("onerror")
|
||||||
@@ -252,9 +375,9 @@ class GeschichteServiceTest {
|
|||||||
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
|
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>");
|
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
GeschichteView saved = geschichteService.create(dto);
|
||||||
|
|
||||||
assertThat(saved.getBody())
|
assertThat(saved.body())
|
||||||
.contains("<h2>Heading</h2>")
|
.contains("<h2>Heading</h2>")
|
||||||
.contains("<strong>bold</strong>")
|
.contains("<strong>bold</strong>")
|
||||||
.contains("<em>italic</em>")
|
.contains("<em>italic</em>")
|
||||||
@@ -277,28 +400,9 @@ class GeschichteServiceTest {
|
|||||||
dto.setTitle("Linked");
|
dto.setTitle("Linked");
|
||||||
dto.setPersonIds(List.of(personId));
|
dto.setPersonIds(List.of(personId));
|
||||||
|
|
||||||
Geschichte saved = geschichteService.create(dto);
|
GeschichteView saved = geschichteService.create(dto);
|
||||||
|
|
||||||
assertThat(saved.getPersons()).containsExactly(person);
|
assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
@Test
|
||||||
@@ -315,6 +419,202 @@ class GeschichteServiceTest {
|
|||||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
.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 & 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 ──────────────────────────────────────────────────────────────
|
// ─── update ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -330,10 +630,10 @@ class GeschichteServiceTest {
|
|||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||||
|
|
||||||
Geschichte saved = geschichteService.update(id, dto);
|
GeschichteView saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||||
assertThat(saved.getPublishedAt()).isNotNull();
|
assertThat(saved.publishedAt()).isNotNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -349,10 +649,10 @@ class GeschichteServiceTest {
|
|||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setStatus(GeschichteStatus.DRAFT);
|
dto.setStatus(GeschichteStatus.DRAFT);
|
||||||
|
|
||||||
Geschichte saved = geschichteService.update(id, dto);
|
GeschichteView saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||||
assertThat(saved.getPublishedAt()).isNull();
|
assertThat(saved.publishedAt()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -366,9 +666,46 @@ class GeschichteServiceTest {
|
|||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||||
|
|
||||||
Geschichte saved = geschichteService.update(id, dto);
|
GeschichteView saved = geschichteService.update(id, dto);
|
||||||
|
|
||||||
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -426,7 +763,7 @@ class GeschichteServiceTest {
|
|||||||
.body("<p>body</p>")
|
.body("<p>body</p>")
|
||||||
.status(GeschichteStatus.DRAFT)
|
.status(GeschichteStatus.DRAFT)
|
||||||
.persons(new HashSet<>())
|
.persons(new HashSet<>())
|
||||||
.documents(new HashSet<>())
|
.items(new ArrayList<>())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +775,7 @@ class GeschichteServiceTest {
|
|||||||
.status(GeschichteStatus.PUBLISHED)
|
.status(GeschichteStatus.PUBLISHED)
|
||||||
.publishedAt(LocalDateTime.now().minusHours(1))
|
.publishedAt(LocalDateTime.now().minusHours(1))
|
||||||
.persons(new HashSet<>())
|
.persons(new HashSet<>())
|
||||||
.documents(new HashSet<>())
|
.items(new ArrayList<>())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,822 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user