Compare commits
350 Commits
45e63307bb
...
worktree-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a9e1c4c40 | ||
|
|
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 | ||
|
|
ffc14dd2ff | ||
|
|
3827a9d059 | ||
|
|
c8931071ba | ||
|
|
da1984b916 | ||
|
|
0422af8980 | ||
|
|
197b668f20 | ||
|
|
5d752fcc0f | ||
|
|
0170f79690 | ||
|
|
369a0213e5 | ||
|
|
a7d0e96613 | ||
|
|
5458ca9bae | ||
|
|
23d93d492d | ||
|
|
2097dddf3a | ||
|
|
585f28cd23 | ||
|
|
2c18cb8b0d | ||
|
|
655f0c3531 | ||
|
|
e7931335ce | ||
|
|
89bb0b5d65 | ||
|
|
b8ad64dd13 | ||
|
|
9bdd9fb3a5 | ||
|
|
52e48a6b8c | ||
|
|
fd624f6ec8 | ||
|
|
6d8655bad1 | ||
|
|
5167a2ae18 | ||
|
|
4f07527b0f | ||
|
|
0c5f56e9d1 | ||
|
|
652100a9c2 | ||
|
|
557f37be54 | ||
|
|
2a462d0a7c | ||
|
|
36bd7e0414 | ||
|
|
6970cc95fb | ||
|
|
a5e3205520 | ||
|
|
f124529ee8 | ||
|
|
61ca5a6e40 | ||
|
|
516a0a3814 | ||
|
|
39276b179d | ||
|
|
577dd3fcb1 | ||
|
|
c0b500b692 | ||
|
|
cb8c85a742 | ||
|
|
c93d3b03ed | ||
|
|
8f163f9b77 | ||
|
|
40511535eb | ||
|
|
a68a822c13 | ||
|
|
df0037cba2 | ||
|
|
dcb5585c64 | ||
|
|
1e77d6d98c | ||
|
|
f22508ca91 | ||
|
|
1cb05697cc | ||
| ccf1661768 | |||
|
|
74cc4c8722 | ||
|
|
548bc60747 | ||
|
|
4581fc0b1f | ||
|
|
8f3c799b8f | ||
|
|
f80dda74f0 | ||
|
|
22603a4b04 | ||
|
|
461a8b125d | ||
|
|
a670ba014c | ||
|
|
a9cac08f3c | ||
|
|
4cc725d546 | ||
|
|
535594378a | ||
|
|
e93b09f1e2 | ||
|
|
46d1f5c6d8 | ||
|
|
07300aeff7 | ||
|
|
643d504c7a | ||
|
|
c9f5f6d665 | ||
|
|
9d9cd644ec | ||
|
|
0a3d12b9af | ||
|
|
34e0eec1ba | ||
|
|
f5e2241fe0 | ||
|
|
f96b9fbffc | ||
|
|
a4c2b6289d | ||
|
|
658277e97c | ||
|
|
32d9a33550 | ||
|
|
f5eb227239 | ||
|
|
227116fe2d | ||
|
|
7183d15fe5 | ||
|
|
b52bf60913 | ||
|
|
3f3d5e530c | ||
|
|
5dac1d993c | ||
|
|
264d60c855 | ||
|
|
e6a0c2f6d6 | ||
|
|
80d77a53e9 | ||
|
|
a45652466e | ||
|
|
49a17b581b | ||
|
|
53c8d6e9f0 | ||
|
|
279b4f1098 | ||
|
|
15114c2d92 | ||
|
|
35017d91c4 | ||
|
|
5b367a53a1 | ||
|
|
cb91ed340d | ||
|
|
2e0eb40aec | ||
|
|
d9e01ef1ff | ||
|
|
2e0f85c360 | ||
|
|
a1035171c2 |
@@ -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
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,3 +30,6 @@ frontend/yarn.lock
|
|||||||
**/.venv/
|
**/.venv/
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
# Canonical import artifacts live only on the ops host (PII).
|
||||||
|
# See tools/import-normalizer/.gitignore — load-bearing for that policy.
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
||||||
├── person/ Person domain
|
├── person/ Person domain
|
||||||
│ └── relationship/ PersonRelationship sub-domain
|
│ └── relationship/ PersonRelationship sub-domain
|
||||||
|
├── search/ NL search domain — NlSearchController, NlQueryParserService, RestClientOllamaClient, NlSearchRateLimiter
|
||||||
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||||
├── tag/ Tag domain
|
├── tag/ Tag domain
|
||||||
└── user/ User domain — AppUser, UserGroup, UserService
|
└── user/ User domain — AppUser, UserGroup, UserService
|
||||||
@@ -160,7 +161,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); `SMART_SEARCH_UNAVAILABLE` (HTTP 503 — Ollama inference service offline or timed out); `SMART_SEARCH_RATE_LIMITED` (HTTP 429 — user exceeded 5 NL search requests per minute).
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
@@ -194,10 +195,10 @@ 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)
|
||||||
|
├── themen/ Topics directory — browsable tag index
|
||||||
├── enrich/ Enrichment workflow — [id], done
|
├── enrich/ Enrichment workflow — [id], done
|
||||||
├── admin/ User, group, tag, OCR, system management
|
├── admin/ User, group, tag, OCR, system management
|
||||||
├── hilfe/transkription/ Transcription help page
|
├── hilfe/transkription/ Transcription help page
|
||||||
@@ -268,7 +269,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); `SMART_SEARCH_UNAVAILABLE` (HTTP 503 — Ollama inference service offline or timed out); `SMART_SEARCH_RATE_LIMITED` (HTTP 429 — user exceeded 5 NL search requests per minute).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,4 +28,18 @@ Authorization: Basic Gast_User gast
|
|||||||
###Groups
|
###Groups
|
||||||
#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>
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import org.raddatz.familienarchiv.person.Person;
|
|||||||
import org.raddatz.familienarchiv.tag.Tag;
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -35,5 +36,9 @@ public record DocumentListItem(
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
List<ActivityActorDTO> contributors,
|
List<ActivityActorDTO> contributors,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
SearchMatchData matchData
|
SearchMatchData matchData,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
LocalDateTime updatedAt
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -58,6 +57,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 +81,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 (
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ 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 +70,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;
|
||||||
@@ -137,8 +140,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 +172,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 +382,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 +433,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 +466,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 +589,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 +607,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,12 +747,24 @@ 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
|
||||||
|
// findAllMatchingIdsByFts entirely (ADR-008). This must run BEFORE any
|
||||||
|
// findAllMatchingIdsByFts call so the fast path is preserved. An active undated
|
||||||
|
// filter must NOT take this path: it bypasses buildSearchSpec, so the
|
||||||
|
// undatedOnly predicate would be silently dropped. By definition this path has
|
||||||
|
// no date/sender/receiver/tag/status filters, and undated documents are valid
|
||||||
|
// FTS hits already folded into the ranked page, so there is no separate undated
|
||||||
|
// count to report here.
|
||||||
|
if (!filters.undated() && isPureTextRelevance(hasText, sort, filters)) {
|
||||||
|
return relevanceSortedPageFromSql(filters.text(), pageable);
|
||||||
|
}
|
||||||
|
|
||||||
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());
|
||||||
}
|
}
|
||||||
@@ -679,42 +772,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,
|
// The pure-text RELEVANCE fast path is handled by the caller (searchDocuments)
|
||||||
DocumentSort sort, String dir, TagOperator tagOperator,
|
// before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008).
|
||||||
boolean undated, Pageable pageable) {
|
Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
|
||||||
// Pure-text RELEVANCE: push pagination into SQL — skip findAllMatchingIdsByFts entirely (ADR-008).
|
String text = filters.text();
|
||||||
// An active undated filter must NOT take this path: it bypasses buildSearchSpec, so the
|
|
||||||
// undatedOnly predicate would be silently dropped.
|
|
||||||
if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
|
|
||||||
return relevanceSortedPageFromSql(text, pageable);
|
|
||||||
}
|
|
||||||
|
|
||||||
Specification<Document> spec = buildSearchSpec(
|
|
||||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
|
|
||||||
|
|
||||||
// SENDER and RECEIVER sorts load the full match set and slice in-memory.
|
// 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
|
||||||
@@ -748,12 +831,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -825,7 +908,9 @@ public class DocumentService {
|
|||||||
doc.getSummary(),
|
doc.getSummary(),
|
||||||
completionPct,
|
completionPct,
|
||||||
contributors,
|
contributors,
|
||||||
match
|
match,
|
||||||
|
doc.getCreatedAt(),
|
||||||
|
doc.getUpdatedAt()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,11 +924,12 @@ public class DocumentService {
|
|||||||
// Undated documents (null documentDate) must order last regardless of
|
// Undated documents (null documentDate) must order last regardless of
|
||||||
// direction — Postgres puts NULLs FIRST on ASC by default, which would
|
// direction — Postgres puts NULLs FIRST on ASC by default, which would
|
||||||
// surface the undated pile at the top with no explanation (issue #668).
|
// surface the undated pile at the top with no explanation (issue #668).
|
||||||
// The createdAt tiebreaker gives a stable total order when every row is
|
// The title tiebreaker gives a stable total order when every row is
|
||||||
// null-dated (the "Nur undatierte" filter), so pagination is deterministic.
|
// null-dated (the "Nur undatierte" filter), so pagination is deterministic.
|
||||||
|
// title is @Column(nullable=false), so it is always present.
|
||||||
return Sort.by(
|
return Sort.by(
|
||||||
new Sort.Order(direction, "documentDate").nullsLast(),
|
new Sort.Order(direction, "documentDate").nullsLast(),
|
||||||
Sort.Order.asc("createdAt"));
|
Sort.Order.asc("title"));
|
||||||
}
|
}
|
||||||
// SENDER and RECEIVER are sorted in-memory before this method is called
|
// SENDER and RECEIVER are sorted in-memory before this method is called
|
||||||
return switch (sort) {
|
return switch (sort) {
|
||||||
@@ -891,22 +977,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);
|
||||||
@@ -936,6 +1006,19 @@ public class DocumentService {
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
}
|
}
|
||||||
@@ -952,13 +1035,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() {
|
||||||
@@ -995,6 +1091,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,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 */
|
||||||
@@ -133,6 +135,12 @@ public enum ErrorCode {
|
|||||||
/** The merge target is a descendant of the source tag. 400 */
|
/** The merge target is a descendant of the source tag. 400 */
|
||||||
TAG_MERGE_INVALID_TARGET,
|
TAG_MERGE_INVALID_TARGET,
|
||||||
|
|
||||||
|
// --- NL Search ---
|
||||||
|
/** Ollama is unreachable or timed out. 503 */
|
||||||
|
SMART_SEARCH_UNAVAILABLE,
|
||||||
|
/** NL search rate limit exceeded (5 requests per user per minute). 429 */
|
||||||
|
SMART_SEARCH_RATE_LIMITED,
|
||||||
|
|
||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
|
|||||||
@@ -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,38 @@ 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.
|
||||||
|
log.warn("Rejected a request that violated a database integrity constraint: {}", constraintNameOf(ex));
|
||||||
|
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);
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
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.relationship.RelationType;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.PersonNodeDTO;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the four canonical loaders in their real dependency order — encoded explicitly
|
* Runs the four canonical loaders in their real dependency order — encoded explicitly
|
||||||
@@ -34,6 +42,7 @@ public class CanonicalImportOrchestrator {
|
|||||||
private final PersonRegisterImporter personRegisterImporter;
|
private final PersonRegisterImporter personRegisterImporter;
|
||||||
private final PersonTreeImporter personTreeImporter;
|
private final PersonTreeImporter personTreeImporter;
|
||||||
private final DocumentImporter documentImporter;
|
private final DocumentImporter documentImporter;
|
||||||
|
private final RelationshipService relationshipService;
|
||||||
|
|
||||||
@Value("${app.import.dir:/import}")
|
@Value("${app.import.dir:/import}")
|
||||||
private String canonicalDir;
|
private String canonicalDir;
|
||||||
@@ -67,6 +76,7 @@ public class CanonicalImportOrchestrator {
|
|||||||
tagTreeImporter.load(tagTree);
|
tagTreeImporter.load(tagTree);
|
||||||
personRegisterImporter.load(persons);
|
personRegisterImporter.load(persons);
|
||||||
personTreeImporter.load(personsTree);
|
personTreeImporter.load(personsTree);
|
||||||
|
warnOnGenerationMonotonicityViolations();
|
||||||
DocumentImporter.LoadResult result = documentImporter.load(documents);
|
DocumentImporter.LoadResult result = documentImporter.load(documents);
|
||||||
|
|
||||||
currentStatus = new ImportStatus(ImportStatus.State.DONE, "IMPORT_DONE",
|
currentStatus = new ImportStatus(ImportStatus.State.DONE, "IMPORT_DONE",
|
||||||
@@ -91,4 +101,31 @@ public class CanonicalImportOrchestrator {
|
|||||||
}
|
}
|
||||||
return artifact;
|
return artifact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks every PARENT_OF edge in the family graph and logs a WARN whenever a child's
|
||||||
|
* generation is not strictly deeper than its parent's. Soft check only — the import
|
||||||
|
* is never aborted; the warning is a forensic signal for the curator. Reads through
|
||||||
|
* {@link RelationshipService} so the orchestrator stays within the layering rule
|
||||||
|
* (no direct repository access).
|
||||||
|
*/
|
||||||
|
private void warnOnGenerationMonotonicityViolations() {
|
||||||
|
NetworkDTO network = relationshipService.getFamilyNetwork();
|
||||||
|
Map<UUID, PersonNodeDTO> byId = new HashMap<>(network.nodes().size());
|
||||||
|
for (PersonNodeDTO node : network.nodes()) {
|
||||||
|
byId.put(node.id(), node);
|
||||||
|
}
|
||||||
|
for (RelationshipDTO edge : network.edges()) {
|
||||||
|
if (edge.relationType() != RelationType.PARENT_OF) continue;
|
||||||
|
PersonNodeDTO parent = byId.get(edge.personId());
|
||||||
|
PersonNodeDTO child = byId.get(edge.relatedPersonId());
|
||||||
|
if (parent == null || child == null) continue;
|
||||||
|
Integer pg = parent.generation();
|
||||||
|
Integer cg = child.generation();
|
||||||
|
if (pg != null && cg != null && cg <= pg) {
|
||||||
|
log.warn("Generation monotonicity violation: parent {} (G{}) -> child {} (G{})",
|
||||||
|
parent.displayName(), pg, child.displayName(), cg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -24,11 +25,9 @@ import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
|||||||
import org.raddatz.familienarchiv.tag.TagService;
|
import org.raddatz.familienarchiv.tag.TagService;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
@@ -38,19 +37,23 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Stream;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads {@code canonical-documents.xlsx} into the document domain. Java performs no
|
* Loads {@code canonical-documents.xlsx} into the document domain. Java performs no
|
||||||
* semantic transformation: the normalizer already resolved people to slugs and dates to
|
* semantic transformation: the normalizer already resolved people to slugs and dates to
|
||||||
* ISO values. This loader maps columns by header name, routes each attribution
|
* ISO values. This loader maps columns by header name, routes each attribution
|
||||||
* register-first (always retaining the raw cell in {@code sender_text}/{@code receiver_text}),
|
* register-first (always retaining the raw cell in {@code sender_text}/{@code receiver_text}),
|
||||||
* parses clean dates, and keeps the file/S3/thumbnail plumbing.
|
* parses clean dates, and keeps the S3/thumbnail plumbing.
|
||||||
*
|
*
|
||||||
* <p>The {@code file} value is hostile input regardless of upstream trust (CWE-22 does not
|
* <p>The import corpus is uniform — every PDF is named {@code <index>.pdf} flat in the import
|
||||||
* care that it came from our Python tool): its basename is validated with
|
* dir — so a document's PDF is resolved <em>directly by its index</em>:
|
||||||
* {@link #isValidImportFilename} and then resolved with canonical-path containment in
|
* {@code importDir.resolve(index + ".pdf")}. The {@code index} is still hostile input
|
||||||
* {@link #findFileRecursive}.
|
* regardless of upstream trust (CWE-22 does not care it came from our Python tool): it is
|
||||||
|
* validated against a strict catalog pattern with {@link #isValidImportIndex} (no path
|
||||||
|
* separators, no {@code .}/{@code ..}, no absolute path, no slash homoglyphs) and the
|
||||||
|
* resolved path is asserted to stay inside the import dir in {@link #resolvePdfByIndex} as
|
||||||
|
* defense-in-depth. The {@code %PDF} magic-byte check still gates upload.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -58,14 +61,26 @@ import java.util.stream.Stream;
|
|||||||
public class DocumentImporter {
|
public class DocumentImporter {
|
||||||
|
|
||||||
static final List<String> REQUIRED_HEADERS = List.of(
|
static final List<String> REQUIRED_HEADERS = List.of(
|
||||||
"index", "file", "sender_person_id", "sender_name",
|
"index", "sender_person_id", "sender_name",
|
||||||
"receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision");
|
"receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision");
|
||||||
|
|
||||||
|
// Catalog index shape: 1–4 letters (ASCII + Latin-1 letters, e.g. the German "ü" in
|
||||||
|
// "Mü-0001"), one or more hyphens (the corpus has a few "C--0029" data-entry artefacts),
|
||||||
|
// digits, and an optional trailing "x" the normalizer recognises. Anchored, with no
|
||||||
|
// separator / dot / slash characters in the class, so "<index>.pdf" can never traverse.
|
||||||
|
// NOTE: `\d` here is intentionally ASCII-only ([0-9]). Java's java.util.regex matches `\d`
|
||||||
|
// against [0-9] unless Pattern.UNICODE_CHARACTER_CLASS is set — do NOT add that flag, or
|
||||||
|
// Arabic-Indic / fullwidth digits would silently widen the accepted set.
|
||||||
|
private static final Pattern INDEX_PATTERN =
|
||||||
|
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;
|
||||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||||
|
private final FileStreamOpener fileStreamOpener;
|
||||||
|
|
||||||
@Value("${app.s3.bucket:familienarchiv}")
|
@Value("${app.s3.bucket:familienarchiv}")
|
||||||
private String bucketName;
|
private String bucketName;
|
||||||
@@ -84,12 +99,16 @@ public class DocumentImporter {
|
|||||||
List<CanonicalSheetReader.Row> rows = CanonicalSheetReader.readRows(artifact, REQUIRED_HEADERS);
|
List<CanonicalSheetReader.Row> rows = CanonicalSheetReader.readRows(artifact, REQUIRED_HEADERS);
|
||||||
int processed = 0;
|
int processed = 0;
|
||||||
List<ImportStatus.SkippedFile> skipped = new ArrayList<>();
|
List<ImportStatus.SkippedFile> skipped = new ArrayList<>();
|
||||||
|
// 1-based source row number for ops triage breadcrumbs (the spreadsheet header is row 1,
|
||||||
|
// so the first data row is row 2 — matches what an operator sees in the .xlsx).
|
||||||
|
int rowNumber = 1;
|
||||||
for (CanonicalSheetReader.Row row : rows) {
|
for (CanonicalSheetReader.Row row : rows) {
|
||||||
|
rowNumber++;
|
||||||
String index = row.get("index");
|
String index = row.get("index");
|
||||||
if (index.isBlank()) continue;
|
if (index.isBlank()) continue;
|
||||||
Optional<ImportStatus.SkipReason> skipReason = importRow(row, index, skipped);
|
Optional<ImportStatus.SkipReason> skipReason = importRow(row, index, rowNumber);
|
||||||
if (skipReason.isPresent()) {
|
if (skipReason.isPresent()) {
|
||||||
skipped.add(new ImportStatus.SkippedFile(displayName(row, index), skipReason.get()));
|
skipped.add(new ImportStatus.SkippedFile(index, skipReason.get()));
|
||||||
} else {
|
} else {
|
||||||
processed++;
|
processed++;
|
||||||
}
|
}
|
||||||
@@ -98,16 +117,24 @@ public class DocumentImporter {
|
|||||||
return new LoadResult(processed, skipped);
|
return new LoadResult(processed, skipped);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<ImportStatus.SkipReason> importRow(CanonicalSheetReader.Row row, String index,
|
private Optional<ImportStatus.SkipReason> importRow(CanonicalSheetReader.Row row, String index, int rowNumber) {
|
||||||
List<ImportStatus.SkippedFile> skipped) {
|
if (!isValidImportIndex(index)) {
|
||||||
Optional<File> resolved;
|
// Breadcrumb is the source row number, NOT the raw (possibly-hostile) index — an
|
||||||
try {
|
// operator triaging the import can find the offending row in the .xlsx without us
|
||||||
resolved = resolveFile(row.get("file"));
|
// echoing attacker-controlled input into the log.
|
||||||
} catch (InvalidImportFilenameException e) {
|
log.warn("Skipping import row {}: index rejected (fails catalog-shape validation)", rowNumber);
|
||||||
log.warn("Skipping import row {}: filename rejected", index);
|
|
||||||
return Optional.of(ImportStatus.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
|
return Optional.of(ImportStatus.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
|
||||||
}
|
}
|
||||||
if (resolved.isPresent()) {
|
Optional<File> resolved = resolvePdfByIndex(index, rowNumber);
|
||||||
|
if (resolved.isEmpty()) {
|
||||||
|
// Distinct from the "index rejected" skip above: the index is VALID but no
|
||||||
|
// <index>.pdf is on disk, so the row becomes a normal PLACEHOLDER (not skipped). The
|
||||||
|
// index is a validated catalog id (no hostile content), so it is safe to log here —
|
||||||
|
// this surfaces a corpus that drifts from the "<index>.pdf" assumption (e.g. a file
|
||||||
|
// that arrived under a different name) rather than dropping it silently.
|
||||||
|
log.info("Import row {}: index {} is valid but {}.pdf is absent — creating PLACEHOLDER",
|
||||||
|
rowNumber, index, index);
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
if (!isPdfMagicBytes(resolved.get())) {
|
if (!isPdfMagicBytes(resolved.get())) {
|
||||||
return Optional.of(ImportStatus.SkipReason.INVALID_PDF_SIGNATURE);
|
return Optional.of(ImportStatus.SkipReason.INVALID_PDF_SIGNATURE);
|
||||||
@@ -153,53 +180,62 @@ public class DocumentImporter {
|
|||||||
String s3Key, String contentType, DocumentStatus status) {
|
String s3Key, String contentType, DocumentStatus status) {
|
||||||
Document doc = existing != null ? existing
|
Document doc = existing != null ? existing
|
||||||
: Document.builder().originalFilename(index).build();
|
: Document.builder().originalFilename(index).build();
|
||||||
|
applyAttribution(doc, row);
|
||||||
String senderName = row.get("sender_name");
|
applyDates(doc, row);
|
||||||
String receiverNames = row.get("receiver_names");
|
applyAuthoritativeAssociations(doc, row);
|
||||||
Person sender = resolveSender(row.get("sender_person_id"), senderName);
|
applyFileMetadata(doc, s3Key, contentType, status);
|
||||||
Set<Person> receivers = resolveReceivers(row.get("receiver_person_ids"));
|
applyComputedFlags(doc);
|
||||||
|
|
||||||
LocalDate date = parseIsoDate(row.get("date_iso"));
|
|
||||||
DatePrecision precision = parsePrecision(row.get("date_precision"));
|
|
||||||
LocalDate dateEnd = parseIsoDate(row.get("date_end"));
|
|
||||||
String dateRaw = blankToNull(row.get("date_raw"));
|
|
||||||
String location = blankToNull(row.get("location"));
|
|
||||||
|
|
||||||
doc.setTitle(buildTitle(index, date, precision, dateEnd, dateRaw, location));
|
|
||||||
doc.setStatus(status);
|
|
||||||
doc.setFilePath(s3Key);
|
|
||||||
doc.setContentType(contentType);
|
|
||||||
doc.setSender(sender);
|
|
||||||
doc.setSenderText(blankToNull(senderName));
|
|
||||||
// The canonical row is authoritative for receivers/tags (ADR-025): clear then
|
|
||||||
// re-populate so a shrunk set on re-import prunes stale links rather than
|
|
||||||
// accumulating them. The raw sender_text/receiver_text retention is separate.
|
|
||||||
doc.getReceivers().clear();
|
|
||||||
doc.getReceivers().addAll(receivers);
|
|
||||||
doc.setReceiverText(blankToNull(receiverNames));
|
|
||||||
doc.setDocumentDate(date);
|
|
||||||
doc.setMetaDatePrecision(precision);
|
|
||||||
doc.setMetaDateEnd(dateEnd);
|
|
||||||
doc.setMetaDateRaw(dateRaw);
|
|
||||||
doc.setLocation(location);
|
|
||||||
doc.setSummary(blankToNull(row.get("summary")));
|
|
||||||
attachTag(doc, row.get("tags"));
|
|
||||||
doc.setMetadataComplete(doc.getDocumentDate() != null || sender != null || !receivers.isEmpty());
|
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The title carries the date at the HONEST precision (never a fabricated day) via the
|
// Sender + raw sender/receiver text. The raw cells are always retained verbatim, even
|
||||||
// shared DocumentTitleFormatter, plus the location — kept under 20 lines by delegating.
|
// when a person is linked — the load-bearing invariant behind the merge story (ADR-025).
|
||||||
private static String buildTitle(String index, LocalDate date, DatePrecision precision,
|
private void applyAttribution(Document doc, CanonicalSheetReader.Row row) {
|
||||||
LocalDate end, String raw, String location) {
|
String senderName = row.get("sender_name");
|
||||||
StringBuilder title = new StringBuilder(index);
|
String receiverNames = row.get("receiver_names");
|
||||||
if (date != null && precision != DatePrecision.UNKNOWN) {
|
Person sender = resolveSender(row.get("sender_person_id"), senderName);
|
||||||
title.append(" – ").append(DocumentTitleFormatter.formatTitleDate(date, precision, end, raw));
|
doc.setSender(sender);
|
||||||
}
|
doc.setSenderText(blankToNull(senderName));
|
||||||
if (location != null && !location.isBlank()) {
|
doc.setReceiverText(blankToNull(receiverNames));
|
||||||
title.append(" – ").append(location);
|
}
|
||||||
}
|
|
||||||
return title.toString();
|
// Date triplet + raw + location. Pure value parsing, no semantic logic.
|
||||||
|
private void applyDates(Document doc, CanonicalSheetReader.Row row) {
|
||||||
|
doc.setDocumentDate(parseIsoDate(row.get("date_iso")));
|
||||||
|
doc.setMetaDatePrecision(parsePrecision(row.get("date_precision")));
|
||||||
|
doc.setMetaDateEnd(parseIsoDate(row.get("date_end")));
|
||||||
|
doc.setMetaDateRaw(blankToNull(row.get("date_raw")));
|
||||||
|
doc.setLocation(blankToNull(row.get("location")));
|
||||||
|
doc.setSummary(blankToNull(row.get("summary")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receivers and tags are owned by the canonical row (ADR-025): clear then re-populate so a
|
||||||
|
// shrunk set on re-import prunes stale links rather than accumulating them. The
|
||||||
|
// "preserve human edits" rule does NOT extend to these collections.
|
||||||
|
private void applyAuthoritativeAssociations(Document doc, CanonicalSheetReader.Row row) {
|
||||||
|
Set<Person> receivers = resolveReceivers(row.get("receiver_person_ids"), row.get("receiver_names"));
|
||||||
|
doc.getReceivers().clear();
|
||||||
|
doc.getReceivers().addAll(receivers);
|
||||||
|
attachTag(doc, row.get("tags"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// S3 key, content type, status, and the index-derived title. The title formula lives in
|
||||||
|
// the document package's DocumentTitleFactory (single source of truth, #726); by this point
|
||||||
|
// applyDates has populated the date/location and originalFilename carries the index.
|
||||||
|
private void applyFileMetadata(Document doc, String s3Key, String contentType,
|
||||||
|
DocumentStatus status) {
|
||||||
|
doc.setStatus(status);
|
||||||
|
doc.setFilePath(s3Key);
|
||||||
|
doc.setContentType(contentType);
|
||||||
|
doc.setTitle(documentTitleFactory.build(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
// metadataComplete: a document counts as fully described if any of the three "who/when"
|
||||||
|
// pieces is filled. Called last so the upstream setters have already populated the doc.
|
||||||
|
private void applyComputedFlags(Document doc) {
|
||||||
|
doc.setMetadataComplete(doc.getDocumentDate() != null
|
||||||
|
|| doc.getSender() != null
|
||||||
|
|| !doc.getReceivers().isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── attribution routing — register-first, always retain raw ─────────────────────
|
// ─── attribution routing — register-first, always retain raw ─────────────────────
|
||||||
@@ -209,10 +245,18 @@ public class DocumentImporter {
|
|||||||
return resolvePerson(slug, rawName);
|
return resolvePerson(slug, rawName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Set<Person> resolveReceivers(String slugs) {
|
// Zips the parallel `receiver_person_ids` and `receiver_names` columns by position so an
|
||||||
|
// unresolved receiver becomes a provisional Person whose lastName is the human name from
|
||||||
|
// `receiver_names`, not the slug. If the names list is shorter than the slugs list (rare —
|
||||||
|
// canonical data zips them 1:1), missing entries fall back to slug-as-name.
|
||||||
|
private Set<Person> resolveReceivers(String slugs, String names) {
|
||||||
|
List<String> slugList = CanonicalSheetReader.splitList(slugs);
|
||||||
|
List<String> nameList = CanonicalSheetReader.splitList(names);
|
||||||
Set<Person> receivers = new LinkedHashSet<>();
|
Set<Person> receivers = new LinkedHashSet<>();
|
||||||
for (String slug : CanonicalSheetReader.splitList(slugs)) {
|
for (int i = 0; i < slugList.size(); i++) {
|
||||||
receivers.add(resolvePerson(slug, slug));
|
String slug = slugList.get(i);
|
||||||
|
String name = i < nameList.size() ? nameList.get(i) : slug;
|
||||||
|
receivers.add(resolvePerson(slug, name));
|
||||||
}
|
}
|
||||||
return receivers;
|
return receivers;
|
||||||
}
|
}
|
||||||
@@ -257,21 +301,6 @@ public class DocumentImporter {
|
|||||||
|
|
||||||
// ─── file handling + S3 (small ≤20-line methods) ─────────────────────────────────
|
// ─── file handling + S3 (small ≤20-line methods) ─────────────────────────────────
|
||||||
|
|
||||||
private Optional<File> resolveFile(String fileColumn) {
|
|
||||||
if (fileColumn == null || fileColumn.isBlank()) return Optional.empty();
|
|
||||||
String basename = basenameOf(fileColumn);
|
|
||||||
if (!isValidImportFilename(basename)) {
|
|
||||||
throw new InvalidImportFilenameException();
|
|
||||||
}
|
|
||||||
return findFileRecursive(basename);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String basenameOf(String fileColumn) {
|
|
||||||
String normalized = fileColumn.replace('\\', '/');
|
|
||||||
int lastSlash = normalized.lastIndexOf('/');
|
|
||||||
return lastSlash < 0 ? normalized.trim() : normalized.substring(lastSlash + 1).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String probeContentType(File file) {
|
private String probeContentType(File file) {
|
||||||
try {
|
try {
|
||||||
String probed = Files.probeContentType(file.toPath());
|
String probed = Files.probeContentType(file.toPath());
|
||||||
@@ -290,29 +319,29 @@ public class DocumentImporter {
|
|||||||
RequestBody.fromFile(file));
|
RequestBody.fromFile(file));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── security guards — ported verbatim from MassImportService — do not weaken ────
|
// ─── index validation + containment — defense-in-depth, do not weaken ────────────
|
||||||
|
|
||||||
private boolean isValidImportFilename(String filename) {
|
// The index is the only thing that drives the on-disk lookup, so it must never contain a
|
||||||
if (filename == null || filename.isBlank()) return false;
|
// path separator, traversal token, slash homoglyph, null byte, or absolute-path marker —
|
||||||
if (filename.contains("/")) return false;
|
// each guard mirrors the filename guards ported from MassImportService — and it must match
|
||||||
if (filename.contains("\\")) return false;
|
// the strict catalog shape so anything unexpected is skipped loudly rather than read.
|
||||||
if (filename.contains("∕")) return false; // U+2215 DIVISION SLASH
|
private boolean isValidImportIndex(String index) {
|
||||||
if (filename.contains("/")) return false; // U+FF0F FULLWIDTH SOLIDUS
|
if (index == null || index.isBlank()) return false;
|
||||||
if (filename.contains("⧵")) return false; // U+29F5 REVERSE SOLIDUS OPERATOR
|
if (index.contains("/")) return false;
|
||||||
if (filename.contains("..")) return false;
|
if (index.contains("\\")) return false;
|
||||||
if (filename.equals(".")) return false;
|
if (index.contains("∕")) return false; // U+2215 DIVISION SLASH
|
||||||
if (filename.contains("\0")) return false;
|
if (index.contains("/")) return false; // U+FF0F FULLWIDTH SOLIDUS
|
||||||
if (Paths.get(filename).isAbsolute()) return false;
|
if (index.contains("⧵")) return false; // U+29F5 REVERSE SOLIDUS OPERATOR
|
||||||
return true;
|
if (index.contains(".")) return false; // no dots — "<index>.pdf" is the only extension
|
||||||
}
|
if (index.contains("\0")) return false;
|
||||||
|
if (Paths.get(index).isAbsolute()) return false;
|
||||||
// package-private: a Mockito spy in tests can override to inject IOException
|
return INDEX_PATTERN.matcher(index).matches();
|
||||||
InputStream openFileStream(File file) throws IOException {
|
|
||||||
return new FileInputStream(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isPdfMagicBytes(File file) throws IOException {
|
private boolean isPdfMagicBytes(File file) throws IOException {
|
||||||
try (InputStream is = openFileStream(file)) {
|
// FileStreamOpener is injected so tests can stub a throwing implementation for the
|
||||||
|
// IO-error branch without spying on the importer itself.
|
||||||
|
try (InputStream is = fileStreamOpener.open(file)) {
|
||||||
byte[] header = is.readNBytes(4);
|
byte[] header = is.readNBytes(4);
|
||||||
return header.length == 4
|
return header.length == 4
|
||||||
&& header[0] == 0x25 // %
|
&& header[0] == 0x25 // %
|
||||||
@@ -322,33 +351,30 @@ public class DocumentImporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<File> findFileRecursive(String filename) {
|
// O(1) direct lookup: the PDF is exactly importDir/<index>.pdf. The caller has already
|
||||||
|
// validated the index shape; the canonical-path containment assertion below is
|
||||||
|
// defense-in-depth so even a symlinked <index>.pdf cannot read outside importDir.
|
||||||
|
private Optional<File> resolvePdfByIndex(String index, int rowNumber) {
|
||||||
File baseDir = new File(importDir);
|
File baseDir = new File(importDir);
|
||||||
try (Stream<Path> walk = Files.walk(baseDir.toPath())) {
|
File candidate = baseDir.toPath().resolve(index + ".pdf").toFile();
|
||||||
Optional<Path> match = walk.filter(p -> !Files.isDirectory(p))
|
try {
|
||||||
.filter(p -> p.getFileName().toString().equals(filename))
|
if (!candidate.isFile()) return Optional.empty();
|
||||||
.findFirst();
|
|
||||||
if (match.isEmpty()) return Optional.empty();
|
|
||||||
File candidate = match.get().toFile();
|
|
||||||
String baseDirCanonical = baseDir.getCanonicalPath();
|
String baseDirCanonical = baseDir.getCanonicalPath();
|
||||||
if (!candidate.getCanonicalPath().startsWith(baseDirCanonical + File.separator)) {
|
if (!candidate.getCanonicalPath().startsWith(baseDirCanonical + File.separator)) {
|
||||||
throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Path escape detected: " + candidate);
|
throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Path escape detected: " + candidate);
|
||||||
}
|
}
|
||||||
return Optional.of(candidate);
|
return Optional.of(candidate);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
// Distinct from the deliberate symlink-escape abort above (which throws): canonical
|
||||||
|
// resolution itself failed (e.g. the OS rejected the path mid-resolution). We fail
|
||||||
|
// safe to a PLACEHOLDER, but never silently — log it so the asymmetry surfaces in ops.
|
||||||
|
log.warn("Canonical path resolution failed for import row {}: treating {}.pdf as absent",
|
||||||
|
rowNumber, index, e);
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String displayName(CanonicalSheetReader.Row row, String index) {
|
|
||||||
String file = row.get("file");
|
|
||||||
return file.isBlank() ? index : basenameOf(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String blankToNull(String s) {
|
private static String blankToNull(String s) {
|
||||||
return (s == null || s.isBlank()) ? null : s;
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class InvalidImportFilenameException extends RuntimeException {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test seam for opening a {@link File} as an {@link InputStream}. Extracted so the magic-byte
|
||||||
|
* check in {@link DocumentImporter} can be unit-tested for the IO-error branch by injecting a
|
||||||
|
* mock that throws, without needing a Mockito spy on the importer itself.
|
||||||
|
*
|
||||||
|
* <p>Production uses {@link DefaultFileStreamOpener}, a one-line delegate to
|
||||||
|
* {@code new FileInputStream(file)}.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface FileStreamOpener {
|
||||||
|
|
||||||
|
/** Opens {@code file} for sequential reads. Caller closes the returned stream. */
|
||||||
|
InputStream open(File file) throws IOException;
|
||||||
|
|
||||||
|
/** Default production implementation: plain {@code FileInputStream}. */
|
||||||
|
@Component
|
||||||
|
final class DefaultFileStreamOpener implements FileStreamOpener {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InputStream open(File file) throws IOException {
|
||||||
|
return new FileInputStream(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.importing;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonGeneration;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.person.PersonType;
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
@@ -11,6 +12,8 @@ import java.io.File;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.format.DateTimeParseException;
|
import java.time.format.DateTimeParseException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads {@code canonical-persons.xlsx} (the register) into the person domain via
|
* Loads {@code canonical-persons.xlsx} (the register) into the person domain via
|
||||||
@@ -25,6 +28,13 @@ public class PersonRegisterImporter {
|
|||||||
|
|
||||||
static final List<String> REQUIRED_HEADERS = List.of("person_id", "last_name", "first_name", "provisional");
|
static final List<String> REQUIRED_HEADERS = List.of("person_id", "last_name", "first_name", "provisional");
|
||||||
|
|
||||||
|
// Matches a leading optional G then a signed integer. Anchored at the
|
||||||
|
// start so noise can't slip in before the number, but tolerant of trailing
|
||||||
|
// commentary cells (e.g. "G 2 de Gruyter") since curated rows sometimes
|
||||||
|
// carry an inline note. Out-of-range values are caught by the post-parse
|
||||||
|
// range guard, not by the regex.
|
||||||
|
private static final Pattern GENERATION_PATTERN = Pattern.compile("^\\s*G?\\s*(-?\\d+)");
|
||||||
|
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
|
|
||||||
public int load(File artifact) {
|
public int load(File artifact) {
|
||||||
@@ -49,11 +59,31 @@ public class PersonRegisterImporter {
|
|||||||
.notes(blankToNull(row.get("notes")))
|
.notes(blankToNull(row.get("notes")))
|
||||||
.birthYear(yearOf(row.get("birth_date")))
|
.birthYear(yearOf(row.get("birth_date")))
|
||||||
.deathYear(yearOf(row.get("death_date")))
|
.deathYear(yearOf(row.get("death_date")))
|
||||||
|
.generation(parseGeneration(row.get("generation"), personId))
|
||||||
.personType(PersonType.PERSON)
|
.personType(PersonType.PERSON)
|
||||||
.provisional(Boolean.parseBoolean(row.get("provisional")))
|
.provisional(Boolean.parseBoolean(row.get("provisional")))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an optional {@code G n} generation cell. Returns null for blanks,
|
||||||
|
* non-matching strings, and any value outside the {@link PersonGeneration}
|
||||||
|
* bounds (mirroring the V70 CHECK). Out-of-range values log a WARN but
|
||||||
|
* never abort the batch — REQ-IMP-001.
|
||||||
|
*/
|
||||||
|
static Integer parseGeneration(String raw, String personId) {
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
Matcher m = GENERATION_PATTERN.matcher(raw);
|
||||||
|
if (!m.find()) return null;
|
||||||
|
int parsed = Integer.parseInt(m.group(1));
|
||||||
|
if (parsed < PersonGeneration.MIN_GENERATION || parsed > PersonGeneration.MAX_GENERATION) {
|
||||||
|
log.warn("Skipping out-of-range generation '{}' for row {}", raw, personId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
log.debug("Parsed generation '{}' for person {}", raw, personId);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
private static Integer yearOf(String isoDate) {
|
private static Integer yearOf(String isoDate) {
|
||||||
if (isoDate == null || isoDate.isBlank()) return null;
|
if (isoDate == null || isoDate.isBlank()) return null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
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;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonGeneration;
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
import org.raddatz.familienarchiv.person.PersonType;
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
@@ -79,12 +80,29 @@ public class PersonTreeImporter {
|
|||||||
.notes(blankToNull(text(node, "notes")))
|
.notes(blankToNull(text(node, "notes")))
|
||||||
.birthYear(intOrNull(node, "birthYear"))
|
.birthYear(intOrNull(node, "birthYear"))
|
||||||
.deathYear(intOrNull(node, "deathYear"))
|
.deathYear(intOrNull(node, "deathYear"))
|
||||||
|
.generation(generationOrNull(node, personId))
|
||||||
.familyMember(node.path("familyMember").asBoolean(false))
|
.familyMember(node.path("familyMember").asBoolean(false))
|
||||||
.personType(PersonType.PERSON)
|
.personType(PersonType.PERSON)
|
||||||
.provisional(false)
|
.provisional(false)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the JSON {@code generation} value if present and within the
|
||||||
|
* {@link PersonGeneration} bounds; null otherwise. Out-of-range values
|
||||||
|
* log a WARN but never abort the batch — mirrors the register-importer
|
||||||
|
* skip-and-warn policy.
|
||||||
|
*/
|
||||||
|
private static Integer generationOrNull(JsonNode node, String personId) {
|
||||||
|
Integer raw = intOrNull(node, "generation");
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (raw < PersonGeneration.MIN_GENERATION || raw > PersonGeneration.MAX_GENERATION) {
|
||||||
|
log.warn("Skipping out-of-range generation '{}' for person {}", raw, personId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
|
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
|
||||||
int created = 0;
|
int created = 0;
|
||||||
for (JsonNode node : relationships) {
|
for (JsonNode node : relationships) {
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ public class Person {
|
|||||||
private Integer birthYear;
|
private Integer birthYear;
|
||||||
private Integer deathYear;
|
private Integer deathYear;
|
||||||
|
|
||||||
|
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
|
||||||
|
// Nullable for persons outside the curated family graph. Drives the
|
||||||
|
// Stammbaum strict-rank seed (see #689) and re-import preserves human
|
||||||
|
// edits via PersonService.preferHuman (ADR-025).
|
||||||
|
@Column(name = "generation")
|
||||||
|
private Integer generation;
|
||||||
|
|
||||||
@Column(name = "family_member", nullable = false)
|
@Column(name = "family_member", nullable = false)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the {@code persons.generation} value range.
|
||||||
|
* The DB CHECK in V70, the {@code PersonUpdateDTO} Bean Validation annotations,
|
||||||
|
* and the canonical importers all reference these constants so a future widening
|
||||||
|
* (e.g. accepting {@code G −1} ancestors) happens in one place. Mirror this file
|
||||||
|
* by hand in the V70 migration comment when adjusting bounds.
|
||||||
|
*/
|
||||||
|
public final class PersonGeneration {
|
||||||
|
|
||||||
|
public static final int MIN_GENERATION = 0;
|
||||||
|
public static final int MAX_GENERATION = 10;
|
||||||
|
|
||||||
|
private PersonGeneration() {}
|
||||||
|
}
|
||||||
@@ -29,14 +29,36 @@ 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 ---
|
||||||
|
|
||||||
@@ -189,18 +211,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 +229,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,5 +1,6 @@
|
|||||||
package org.raddatz.familienarchiv.person;
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -68,15 +69,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 +99,10 @@ public class PersonService {
|
|||||||
return personRepository.findAllById(ids);
|
return personRepository.findAllById(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Person> findByDisplayNameContaining(String fragment) {
|
||||||
|
return personRepository.searchByName(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
public List<Person> findAllFamilyMembers() {
|
public List<Person> findAllFamilyMembers() {
|
||||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||||
}
|
}
|
||||||
@@ -112,7 +115,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 +142,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,6 +205,7 @@ public class PersonService {
|
|||||||
.notes(blankToNull(cmd.notes()))
|
.notes(blankToNull(cmd.notes()))
|
||||||
.birthYear(cmd.birthYear())
|
.birthYear(cmd.birthYear())
|
||||||
.deathYear(cmd.deathYear())
|
.deathYear(cmd.deathYear())
|
||||||
|
.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())
|
||||||
.provisional(cmd.provisional())
|
.provisional(cmd.provisional())
|
||||||
@@ -200,6 +229,7 @@ public class PersonService {
|
|||||||
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
||||||
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
|
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
|
||||||
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
|
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
@@ -254,6 +284,7 @@ public class PersonService {
|
|||||||
.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())
|
.birthYear(dto.getBirthYear())
|
||||||
.deathYear(dto.getDeathYear())
|
.deathYear(dto.getDeathYear())
|
||||||
|
.generation(dto.getGeneration())
|
||||||
.build();
|
.build();
|
||||||
return personRepository.save(person);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
@@ -286,9 +317,18 @@ public class PersonService {
|
|||||||
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.setBirthYear(dto.getBirthYear());
|
||||||
person.setDeathYear(dto.getDeathYear());
|
person.setDeathYear(dto.getDeathYear());
|
||||||
|
// Form path: a human can clear generation back to null. Unlike the importer
|
||||||
|
// which routes through preferHuman, we write the DTO value verbatim.
|
||||||
|
person.setGeneration(dto.getGeneration());
|
||||||
return personRepository.save(person);
|
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)) {
|
||||||
@@ -305,9 +345,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,7 @@
|
|||||||
package org.raddatz.familienarchiv.person;
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
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;
|
||||||
@@ -21,4 +23,9 @@ public class PersonUpdateDTO {
|
|||||||
private String notes;
|
private String notes;
|
||||||
private Integer birthYear;
|
private Integer birthYear;
|
||||||
private Integer deathYear;
|
private Integer deathYear;
|
||||||
|
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
|
||||||
|
// PersonGeneration so DB, DTO, and importer all read from one place.
|
||||||
|
@Min(PersonGeneration.MIN_GENERATION)
|
||||||
|
@Max(PersonGeneration.MAX_GENERATION)
|
||||||
|
private Integer generation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public record PersonUpsertCommand(
|
|||||||
String notes,
|
String notes,
|
||||||
Integer birthYear,
|
Integer birthYear,
|
||||||
Integer deathYear,
|
Integer deathYear,
|
||||||
|
Integer generation,
|
||||||
boolean familyMember,
|
boolean familyMember,
|
||||||
PersonType personType,
|
PersonType personType,
|
||||||
boolean provisional
|
boolean provisional
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ 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 |
|
| `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,8 @@ 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.isFamilyMember());
|
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
||||||
|
p.getGeneration(), p.isFamilyMember());
|
||||||
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
||||||
}
|
}
|
||||||
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
|
out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops)
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ import java.util.UUID;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RelationshipService {
|
public class RelationshipService {
|
||||||
|
|
||||||
|
// Single source of truth for which relationship types are part of the family graph.
|
||||||
|
// Consulted by addRelationship (to set family_member on both endpoints) and by
|
||||||
|
// getFamilyNetwork (to filter the edges returned). FRIEND/COLLEAGUE/etc. are excluded.
|
||||||
|
private static final List<RelationType> FAMILY_RELATION_TYPES =
|
||||||
|
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF);
|
||||||
|
|
||||||
private final PersonRelationshipRepository relationshipRepository;
|
private final PersonRelationshipRepository relationshipRepository;
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
private final RelationshipInferenceService inferenceService;
|
private final RelationshipInferenceService inferenceService;
|
||||||
@@ -60,11 +66,12 @@ 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(), true));
|
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
||||||
|
p.getGeneration(), true));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
|
List<PersonRelationship> familyEdges = relationshipRepository.findAllByRelationTypeIn(
|
||||||
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
|
FAMILY_RELATION_TYPES);
|
||||||
|
|
||||||
List<RelationshipDTO> edges = new ArrayList<>();
|
List<RelationshipDTO> edges = new ArrayList<>();
|
||||||
for (PersonRelationship r : familyEdges) {
|
for (PersonRelationship r : familyEdges) {
|
||||||
@@ -105,15 +112,23 @@ public class RelationshipService {
|
|||||||
.notes(blankToNull(dto.notes()))
|
.notes(blankToNull(dto.notes()))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
PersonRelationship saved;
|
||||||
try {
|
try {
|
||||||
// saveAndFlush so the unique_rel constraint violates synchronously and is
|
// saveAndFlush so the unique_rel constraint violates synchronously and is
|
||||||
// caught here, not at commit time outside the @Transactional boundary.
|
// caught here, not at commit time outside the @Transactional boundary.
|
||||||
return toDTO(relationshipRepository.saveAndFlush(rel));
|
saved = relationshipRepository.saveAndFlush(rel);
|
||||||
} catch (DataIntegrityViolationException e) {
|
} catch (DataIntegrityViolationException e) {
|
||||||
throw DomainException.conflict(
|
throw DomainException.conflict(
|
||||||
ErrorCode.DUPLICATE_RELATIONSHIP,
|
ErrorCode.DUPLICATE_RELATIONSHIP,
|
||||||
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
|
"Relationship already exists for (" + personId + ", " + relatedPerson.getId() + ", " + dto.relationType() + ")");
|
||||||
}
|
}
|
||||||
|
// Family-graph edges imply both endpoints are family members. Idempotent: the
|
||||||
|
// setter is a no-op when the person is already flagged, so re-imports stay clean.
|
||||||
|
if (FAMILY_RELATION_TYPES.contains(dto.relationType())) {
|
||||||
|
personService.setFamilyMember(person.getId(), true);
|
||||||
|
personService.setFamilyMember(relatedPerson.getId(), true);
|
||||||
|
}
|
||||||
|
return toDTO(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ public record PersonNodeDTO(
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName,
|
||||||
Integer birthYear,
|
Integer birthYear,
|
||||||
Integer deathYear,
|
Integer deathYear,
|
||||||
|
Integer generation,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean familyMember
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record NlQueryInterpretation(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
List<PersonHint> resolvedPersons,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
List<PersonHint> ambiguousPersons,
|
||||||
|
LocalDate dateFrom,
|
||||||
|
LocalDate dateTo,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
List<String> keywords,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
String rawQuery,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
boolean keywordsApplied
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||||
|
import org.raddatz.familienarchiv.document.SearchFilters;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class NlQueryParserService {
|
||||||
|
|
||||||
|
private static final int MIN_QUERY = 3;
|
||||||
|
private static final int MAX_QUERY = 500;
|
||||||
|
private static final int MAX_NAME_LENGTH = 200;
|
||||||
|
private static final int MAX_CANDIDATES = 10;
|
||||||
|
|
||||||
|
private final OllamaClient ollamaClient;
|
||||||
|
private final PersonService personService;
|
||||||
|
private final DocumentService documentService;
|
||||||
|
|
||||||
|
public NlSearchResponse search(String query, Pageable pageable) {
|
||||||
|
if (query == null || query.length() < MIN_QUERY || query.length() > MAX_QUERY) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Query must be between " + MIN_QUERY + " and " + MAX_QUERY + " characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
OllamaExtraction ext = ollamaClient.parse(query);
|
||||||
|
|
||||||
|
List<String> personNames = ext.personNames() != null ? ext.personNames() : List.of();
|
||||||
|
List<String> keywords = ext.keywords() != null ? ext.keywords() : List.of();
|
||||||
|
|
||||||
|
NameResolution resolution = resolveNames(personNames);
|
||||||
|
|
||||||
|
if (!resolution.ambiguous().isEmpty()) {
|
||||||
|
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||||
|
List.of(), resolution.ambiguous(),
|
||||||
|
ext.dateFrom(), ext.dateTo(),
|
||||||
|
keywords, ext.rawQuery(), false);
|
||||||
|
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PersonHint> resolved = resolution.resolved();
|
||||||
|
List<String> noMatchFragments = resolution.noMatchFragments();
|
||||||
|
List<String> extraFragments = resolution.extraFragments();
|
||||||
|
|
||||||
|
String text = buildText(keywords, noMatchFragments, extraFragments, ext.rawQuery());
|
||||||
|
|
||||||
|
if (resolved.size() == 1 && isAnyRole(ext.personRole())) {
|
||||||
|
UUID personId = resolved.get(0).id();
|
||||||
|
DocumentSearchResult docs = documentService.searchDocumentsByPersonId(
|
||||||
|
personId, ext.dateFrom(), ext.dateTo(), pageable);
|
||||||
|
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||||
|
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, ext.rawQuery(), false);
|
||||||
|
return new NlSearchResponse(docs, interpretation);
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID sender = buildSender(resolved, ext.personRole());
|
||||||
|
UUID receiver = buildReceiver(resolved, ext.personRole());
|
||||||
|
|
||||||
|
SearchFilters filters = new SearchFilters(
|
||||||
|
text.isBlank() ? null : text,
|
||||||
|
ext.dateFrom(), ext.dateTo(),
|
||||||
|
sender, receiver,
|
||||||
|
List.of(), null,
|
||||||
|
null, TagOperator.AND, false);
|
||||||
|
|
||||||
|
DocumentSearchResult docs = documentService.searchDocuments(filters, DocumentSort.DATE, "desc", pageable);
|
||||||
|
boolean keywordsApplied = !text.isBlank();
|
||||||
|
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||||
|
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, ext.rawQuery(), keywordsApplied);
|
||||||
|
return new NlSearchResponse(docs, interpretation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private NameResolution resolveNames(List<String> personNames) {
|
||||||
|
List<PersonHint> resolved = new ArrayList<>();
|
||||||
|
List<PersonHint> ambiguous = new ArrayList<>();
|
||||||
|
List<String> noMatchFragments = new ArrayList<>();
|
||||||
|
List<String> extraFragments = new ArrayList<>();
|
||||||
|
|
||||||
|
int resolvedIndex = 0;
|
||||||
|
for (String name : personNames) {
|
||||||
|
if (name == null || name.length() > MAX_NAME_LENGTH) {
|
||||||
|
log.debug("Skipping name fragment (too long or null): length={}", name == null ? 0 : name.length());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<Person> candidates = personService.findByDisplayNameContaining(name);
|
||||||
|
List<Person> capped = candidates.size() > MAX_CANDIDATES
|
||||||
|
? candidates.subList(0, MAX_CANDIDATES)
|
||||||
|
: candidates;
|
||||||
|
|
||||||
|
if (capped.isEmpty()) {
|
||||||
|
noMatchFragments.add(name);
|
||||||
|
} else if (capped.size() == 1) {
|
||||||
|
Person p = capped.get(0);
|
||||||
|
PersonHint hint = new PersonHint(p.getId(), p.getDisplayName());
|
||||||
|
resolvedIndex++;
|
||||||
|
if (resolvedIndex <= 2) {
|
||||||
|
resolved.add(hint);
|
||||||
|
} else {
|
||||||
|
extraFragments.add(name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
capped.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildText(List<String> keywords, List<String> noMatchFragments,
|
||||||
|
List<String> extraFragments, String rawQuery) {
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
parts.addAll(keywords);
|
||||||
|
parts.addAll(noMatchFragments);
|
||||||
|
parts.addAll(extraFragments);
|
||||||
|
String text = String.join(" ", parts).strip();
|
||||||
|
if (text.isBlank() && rawQuery != null && !rawQuery.isBlank()) {
|
||||||
|
return rawQuery;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAnyRole(String role) {
|
||||||
|
return role == null || "any".equals(role) || (!"sender".equals(role) && !"receiver".equals(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID buildSender(List<PersonHint> resolved, String role) {
|
||||||
|
if (resolved.size() >= 2) return resolved.get(0).id();
|
||||||
|
if (resolved.size() == 1 && "sender".equals(role)) return resolved.get(0).id();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID buildReceiver(List<PersonHint> resolved, String role) {
|
||||||
|
if (resolved.size() >= 2) return resolved.get(1).id();
|
||||||
|
if (resolved.size() == 1 && "receiver".equals(role)) return resolved.get(0).id();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record NameResolution(
|
||||||
|
List<PersonHint> resolved,
|
||||||
|
List<PersonHint> ambiguous,
|
||||||
|
List<String> noMatchFragments,
|
||||||
|
List<String> extraFragments
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/search/nl")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class NlSearchController {
|
||||||
|
|
||||||
|
private final NlQueryParserService nlQueryParserService;
|
||||||
|
private final NlSearchRateLimiter rateLimiter;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
|
public NlSearchResponse search(@Valid @RequestBody NlSearchRequest request,
|
||||||
|
Pageable pageable,
|
||||||
|
@AuthenticationPrincipal UserDetails principal) {
|
||||||
|
rateLimiter.checkAndConsume(principal.getUsername());
|
||||||
|
return nlQueryParserService.search(request.query(), pageable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties("app.nl-search.rate-limit")
|
||||||
|
@Data
|
||||||
|
public class NlSearchRateLimitProperties {
|
||||||
|
private int maxRequestsPerMinute = 5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import com.github.benmanes.caffeine.cache.LoadingCache;
|
||||||
|
import io.github.bucket4j.Bandwidth;
|
||||||
|
import io.github.bucket4j.Bucket;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class NlSearchRateLimiter {
|
||||||
|
|
||||||
|
private final LoadingCache<String, Bucket> byUser;
|
||||||
|
private final int maxRequestsPerMinute;
|
||||||
|
|
||||||
|
public NlSearchRateLimiter(NlSearchRateLimitProperties props) {
|
||||||
|
this.maxRequestsPerMinute = props.getMaxRequestsPerMinute();
|
||||||
|
this.byUser = Caffeine.newBuilder()
|
||||||
|
.expireAfterAccess(1, TimeUnit.MINUTES)
|
||||||
|
.build(key -> newBucket(maxRequestsPerMinute));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void checkAndConsume(String userKey) {
|
||||||
|
if (!byUser.get(userKey).tryConsume(1)) {
|
||||||
|
throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_RATE_LIMITED,
|
||||||
|
"NL search rate limit exceeded for user: " + userKey, 60L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetForTest() {
|
||||||
|
byUser.invalidateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bucket newBucket(int limit) {
|
||||||
|
return Bucket.builder()
|
||||||
|
.addLimit(Bandwidth.builder()
|
||||||
|
.capacity(limit)
|
||||||
|
.refillGreedy(limit, Duration.ofMinutes(1))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public record NlSearchRequest(
|
||||||
|
@NotBlank
|
||||||
|
@Size(min = 3, max = 500)
|
||||||
|
String query
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||||
|
|
||||||
|
public record NlSearchResponse(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
DocumentSearchResult result,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
NlQueryInterpretation interpretation
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
public interface OllamaClient {
|
||||||
|
OllamaExtraction parse(String query);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw structured output from Ollama after parsing and sanitising.
|
||||||
|
* personRole is always one of "sender", "receiver", "any" — defensive parsing ensures this.
|
||||||
|
*/
|
||||||
|
record OllamaExtraction(
|
||||||
|
List<String> personNames,
|
||||||
|
String personRole,
|
||||||
|
LocalDate dateFrom,
|
||||||
|
LocalDate dateTo,
|
||||||
|
List<String> keywords,
|
||||||
|
String rawQuery
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
public interface OllamaHealthClient {
|
||||||
|
boolean isHealthy();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties("app.ollama")
|
||||||
|
@Data
|
||||||
|
public class OllamaProperties {
|
||||||
|
private String baseUrl;
|
||||||
|
private String model;
|
||||||
|
private int timeoutSeconds = 30;
|
||||||
|
private int healthCheckTimeoutSeconds = 2;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record PersonHint(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
String displayName
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.Year;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class RestClientOllamaClient implements OllamaClient, OllamaHealthClient {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final Set<String> VALID_ROLES = Set.of("sender", "receiver", "any");
|
||||||
|
private static final int MAX_NAME_LENGTH = 200;
|
||||||
|
private static final int MAX_KEYWORD_LENGTH = 100;
|
||||||
|
|
||||||
|
private static final Map<String, Object> JSON_SCHEMA = Map.of(
|
||||||
|
"type", "object",
|
||||||
|
"required", List.of("personNames", "personRole", "keywords"),
|
||||||
|
"properties", Map.of(
|
||||||
|
"personNames", Map.of("type", "array", "items", Map.of("type", "string", "maxLength", MAX_NAME_LENGTH)),
|
||||||
|
"personRole", Map.of("type", "string", "enum", List.of("sender", "receiver", "any")),
|
||||||
|
"dateFrom", Map.of("type", List.of("string", "null"), "maxLength", 20),
|
||||||
|
"dateTo", Map.of("type", List.of("string", "null"), "maxLength", 20),
|
||||||
|
"keywords", Map.of("type", "array", "items", Map.of("type", "string", "maxLength", MAX_KEYWORD_LENGTH))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
private final RestClient inferenceClient;
|
||||||
|
private final RestClient healthClient;
|
||||||
|
private final OllamaProperties props;
|
||||||
|
|
||||||
|
public RestClientOllamaClient(OllamaProperties props) {
|
||||||
|
this.props = props;
|
||||||
|
|
||||||
|
HttpClient inferenceHttp = HttpClient.newBuilder()
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
JdkClientHttpRequestFactory inferenceFactory = new JdkClientHttpRequestFactory(inferenceHttp);
|
||||||
|
inferenceFactory.setReadTimeout(Duration.ofSeconds(props.getTimeoutSeconds()));
|
||||||
|
this.inferenceClient = RestClient.builder()
|
||||||
|
.baseUrl(props.getBaseUrl())
|
||||||
|
.requestFactory(inferenceFactory)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpClient healthHttp = HttpClient.newBuilder()
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.connectTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds()))
|
||||||
|
.build();
|
||||||
|
JdkClientHttpRequestFactory healthFactory = new JdkClientHttpRequestFactory(healthHttp);
|
||||||
|
healthFactory.setReadTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds()));
|
||||||
|
this.healthClient = RestClient.builder()
|
||||||
|
.baseUrl(props.getBaseUrl())
|
||||||
|
.requestFactory(healthFactory)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OllamaExtraction parse(String query) {
|
||||||
|
try {
|
||||||
|
OllamaGenerateRequest request = new OllamaGenerateRequest(
|
||||||
|
props.getModel(), query, JSON_SCHEMA, false);
|
||||||
|
String responseBody = inferenceClient.post()
|
||||||
|
.uri("/api/generate")
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.body(request)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
return parseOllamaResponse(responseBody, query);
|
||||||
|
} catch (DomainException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Ollama inference failed: {}", e.getClass().getSimpleName());
|
||||||
|
throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE,
|
||||||
|
"Ollama unavailable: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isHealthy() {
|
||||||
|
try {
|
||||||
|
healthClient.get().uri("/api/tags").retrieve().toBodilessEntity();
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OllamaExtraction parseOllamaResponse(String responseBody, String rawQuery) {
|
||||||
|
try {
|
||||||
|
OllamaGenerateResponse response = MAPPER.readValue(responseBody, OllamaGenerateResponse.class);
|
||||||
|
String inner = response.response();
|
||||||
|
if (inner == null || inner.isBlank()) {
|
||||||
|
return fallbackExtraction(rawQuery);
|
||||||
|
}
|
||||||
|
RawOllamaOutput raw = MAPPER.readValue(inner, RawOllamaOutput.class);
|
||||||
|
return toExtraction(raw, rawQuery);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse Ollama response: {}", e.getClass().getSimpleName());
|
||||||
|
throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE,
|
||||||
|
"Failed to parse Ollama response: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OllamaExtraction toExtraction(RawOllamaOutput raw, String rawQuery) {
|
||||||
|
List<String> names = raw.personNames() == null ? List.of() : raw.personNames().stream()
|
||||||
|
.filter(n -> n != null && n.length() <= MAX_NAME_LENGTH)
|
||||||
|
.toList();
|
||||||
|
List<String> keywords = raw.keywords() == null ? List.of() : raw.keywords().stream()
|
||||||
|
.filter(k -> k != null && k.length() <= MAX_KEYWORD_LENGTH)
|
||||||
|
.toList();
|
||||||
|
String role = sanitiseRole(raw.personRole());
|
||||||
|
LocalDate dateFrom = parseDate(raw.dateFrom(), true);
|
||||||
|
LocalDate dateTo = parseDate(raw.dateTo(), false);
|
||||||
|
return new OllamaExtraction(names, role, dateFrom, dateTo, keywords, rawQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OllamaExtraction fallbackExtraction(String rawQuery) {
|
||||||
|
return new OllamaExtraction(List.of(), "any", null, null, List.of(), rawQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sanitiseRole(String role) {
|
||||||
|
if (role != null && VALID_ROLES.contains(role)) {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
log.warn("Unexpected personRole from Ollama: {}", role);
|
||||||
|
return "any";
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate parseDate(String raw, boolean isFrom) {
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(raw, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||||
|
} catch (DateTimeParseException ignored) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int year = Integer.parseInt(raw.strip());
|
||||||
|
if (year > 1000 && year < 3000) {
|
||||||
|
return isFrom ? Year.of(year).atDay(1) : Year.of(year).atMonth(12).atEndOfMonth();
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
private record OllamaGenerateResponse(String response) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
private record RawOllamaOutput(
|
||||||
|
@JsonProperty("personNames") List<String> personNames,
|
||||||
|
@JsonProperty("personRole") String personRole,
|
||||||
|
@JsonProperty("dateFrom") String dateFrom,
|
||||||
|
@JsonProperty("dateTo") String dateTo,
|
||||||
|
@JsonProperty("keywords") List<String> keywords
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record OllamaGenerateRequest(
|
||||||
|
String model,
|
||||||
|
String prompt,
|
||||||
|
Object format,
|
||||||
|
boolean stream
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -55,10 +56,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 +184,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 +279,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,7 @@ springdoc:
|
|||||||
swagger-ui:
|
swagger-ui:
|
||||||
enabled: true
|
enabled: true
|
||||||
path: /swagger-ui.html
|
path: /swagger-ui.html
|
||||||
|
|
||||||
|
app:
|
||||||
|
ollama:
|
||||||
|
base-url: http://localhost:11434
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ app:
|
|||||||
# The loader maps columns by header name — no positional indices (see ADR-025).
|
# The loader maps columns by header name — no positional indices (see ADR-025).
|
||||||
dir: ${IMPORT_DIR:/import}
|
dir: ${IMPORT_DIR:/import}
|
||||||
|
|
||||||
|
ollama:
|
||||||
|
base-url: http://ollama:11434
|
||||||
|
model: qwen2.5:7b-instruct-q4_K_M
|
||||||
|
timeout-seconds: 30
|
||||||
|
health-check-timeout-seconds: 2
|
||||||
|
|
||||||
|
nl-search:
|
||||||
|
rate-limit:
|
||||||
|
max-requests-per-minute: 5
|
||||||
|
|
||||||
ocr:
|
ocr:
|
||||||
sender-model:
|
sender-model:
|
||||||
activation-threshold: 100
|
activation-threshold: 100
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- #689: persist the hand-curated "G 0…G 5" generation index from
|
||||||
|
-- canonical-persons.xlsx so the Stammbaum layout can use it as a strict
|
||||||
|
-- rank anchor (replacing the current iterative longest-path heuristic that
|
||||||
|
-- silently misplaces loose spouses with their own parents in the graph).
|
||||||
|
--
|
||||||
|
-- Nullable: pre-import rows and persons outside the curated family graph
|
||||||
|
-- legitimately have no generation. The canonical importer back-fills via
|
||||||
|
-- preferHuman on the next run; a human-edited value is never overwritten
|
||||||
|
-- (see ADR-025).
|
||||||
|
|
||||||
|
ALTER TABLE persons ADD COLUMN generation SMALLINT;
|
||||||
|
|
||||||
|
-- Allowlist of valid generation indices. The 0..10 bounds mirror
|
||||||
|
-- PersonGeneration.MIN_GENERATION / MAX_GENERATION in Java — keep the
|
||||||
|
-- two in sync (the DTO @Min/@Max and both importer range guards read from
|
||||||
|
-- those Java constants). Current data tops out at G 5, but a future G 6 →
|
||||||
|
-- G 10 widening needs no migration. A G −1 ancestor would require a
|
||||||
|
-- separate one-shot shift migration (out of scope here; the layout's
|
||||||
|
-- normalise step already handles negative seeds at render time).
|
||||||
|
ALTER TABLE persons ADD CONSTRAINT chk_generation_range
|
||||||
|
CHECK (generation IS NULL OR generation BETWEEN 0 AND 10);
|
||||||
|
|
||||||
|
-- Partial index: only the curated rows (≈ 163 of 1,105) ever get a value,
|
||||||
|
-- and the layout only ever queries for non-null rows.
|
||||||
|
CREATE INDEX idx_persons_generation ON persons (generation)
|
||||||
|
WHERE generation IS NOT NULL;
|
||||||
@@ -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;
|
||||||
@@ -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,12 +181,13 @@ 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,
|
||||||
List.of(), List.of(), null, null, null, null,
|
List.of(), List.of(), null, null, null, null,
|
||||||
0, List.of(), matchData))));
|
0, List.of(), matchData,
|
||||||
|
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)))));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -199,12 +201,13 @@ 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,
|
||||||
List.of(), List.of(), null, null, null, null,
|
List.of(), List.of(), null, null, null, null,
|
||||||
0, List.of(), matchData))));
|
0, List.of(), matchData,
|
||||||
|
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)))));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -221,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"))
|
||||||
@@ -266,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"))
|
||||||
@@ -274,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);
|
||||||
@@ -295,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 {
|
||||||
@@ -412,6 +422,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 {
|
||||||
@@ -1192,7 +1209,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"))
|
||||||
@@ -1205,13 +1222,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
|
||||||
@@ -1221,7 +1258,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"))
|
||||||
@@ -1386,16 +1423,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,8 +123,8 @@ 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(); }))
|
||||||
@@ -137,8 +138,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()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -17,7 +18,8 @@ class DocumentSearchResultTest {
|
|||||||
docId, "Test", "test.pdf", null, null,
|
docId, "Test", "test.pdf", null, null,
|
||||||
DatePrecision.UNKNOWN, null, null,
|
DatePrecision.UNKNOWN, null, null,
|
||||||
List.of(), List.of(), null, null, null, null,
|
List.of(), List.of(), null, null, null, null,
|
||||||
0, List.of(), SearchMatchData.empty());
|
0, List.of(), SearchMatchData.empty(),
|
||||||
|
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -68,7 +70,8 @@ class DocumentSearchResultTest {
|
|||||||
id, "T", "t.pdf", null, null,
|
id, "T", "t.pdf", null, null,
|
||||||
DatePrecision.UNKNOWN, null, null,
|
DatePrecision.UNKNOWN, null, null,
|
||||||
List.of(), List.of(), null, null, null, null,
|
List.of(), List.of(), null, null, null, null,
|
||||||
75, List.of(actor), SearchMatchData.empty());
|
75, List.of(actor), SearchMatchData.empty(),
|
||||||
|
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0));
|
||||||
|
|
||||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
|
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -84,7 +85,8 @@ class DocumentServiceSortTest {
|
|||||||
.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());
|
||||||
@@ -102,7 +104,8 @@ class DocumentServiceSortTest {
|
|||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||||
|
|
||||||
DocumentSearchResult result = documentService.searchDocuments(
|
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);
|
||||||
}
|
}
|
||||||
@@ -119,7 +122,8 @@ class DocumentServiceSortTest {
|
|||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
when(documentRepository.findAllById(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());
|
||||||
@@ -152,8 +156,8 @@ class DocumentServiceSortTest {
|
|||||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
|
when(documentRepository.findAllById(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;
|
||||||
@@ -45,6 +47,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 +75,9 @@ class DocumentServiceTest {
|
|||||||
@Mock AuditLogQueryService auditLogQueryService;
|
@Mock AuditLogQueryService auditLogQueryService;
|
||||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||||
|
// 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 ───────────────────────────────────────────────────────
|
||||||
@@ -118,6 +124,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 +208,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 +227,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 +695,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 +1395,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 +1773,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 +1787,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 +1804,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,14 +1821,20 @@ 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");
|
||||||
assertThat(dateOrder).isNotNull();
|
assertThat(dateOrder).isNotNull();
|
||||||
assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.DESC);
|
assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.DESC);
|
||||||
assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST);
|
assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST);
|
||||||
|
// Owner-decided tiebreaker (#668): title ASC, not createdAt.
|
||||||
|
Sort.Order tiebreak = captor.getValue().getSort().getOrderFor("title");
|
||||||
|
assertThat(tiebreak).isNotNull();
|
||||||
|
assertThat(tiebreak.getDirection()).isEqualTo(Sort.Direction.ASC);
|
||||||
|
assertThat(captor.getValue().getSort().getOrderFor("createdAt")).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1473,14 +1845,20 @@ 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");
|
||||||
assertThat(dateOrder).isNotNull();
|
assertThat(dateOrder).isNotNull();
|
||||||
assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.ASC);
|
assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.ASC);
|
||||||
assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST);
|
assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST);
|
||||||
|
// Owner-decided tiebreaker (#668): title ASC, not createdAt.
|
||||||
|
Sort.Order tiebreak = captor.getValue().getSort().getOrderFor("title");
|
||||||
|
assertThat(tiebreak).isNotNull();
|
||||||
|
assertThat(tiebreak.getDirection()).isEqualTo(Sort.Direction.ASC);
|
||||||
|
assertThat(captor.getValue().getSort().getOrderFor("createdAt")).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1489,8 +1867,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())
|
||||||
@@ -1513,8 +1892,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);
|
||||||
@@ -1537,8 +1917,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);
|
||||||
@@ -1551,7 +1932,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));
|
||||||
}
|
}
|
||||||
@@ -1561,7 +1943,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));
|
||||||
}
|
}
|
||||||
@@ -1597,35 +1980,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
|
||||||
@@ -1639,7 +1993,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");
|
||||||
@@ -1659,7 +2014,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");
|
||||||
@@ -1692,7 +2048,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;
|
||||||
@@ -1723,7 +2080,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)
|
||||||
@@ -1746,7 +2104,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.
|
||||||
@@ -1762,8 +2121,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");
|
||||||
@@ -1786,7 +2146,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)
|
||||||
@@ -1809,7 +2170,8 @@ class DocumentServiceTest {
|
|||||||
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();
|
||||||
@@ -1823,7 +2185,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();
|
||||||
}
|
}
|
||||||
@@ -1843,7 +2206,8 @@ class DocumentServiceTest {
|
|||||||
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");
|
||||||
@@ -2360,7 +2724,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());
|
||||||
}
|
}
|
||||||
@@ -2375,7 +2739,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
|
||||||
@@ -2388,7 +2752,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,51 @@ class UndatedDocumentOrderingIntegrationTest {
|
|||||||
assertThat(result.get(3).getDocumentDate()).isNull();
|
assertThat(result.get(3).getDocumentDate()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameDate_tiebreaksByTitleAsc_notCreatedAt_forBothDirections() throws Exception {
|
||||||
|
// Owner decision (#668): equal-date rows tie-break by title ASC, NOT
|
||||||
|
// createdAt. Insert two same-date docs so that createdAt order (insertion
|
||||||
|
// order) is the OPPOSITE of title order: the first-saved doc gets the later
|
||||||
|
// title ("zzz-first"), the second-saved doc gets the earlier title
|
||||||
|
// ("aaa-second"). If the tiebreaker were still createdAt-asc the first-saved
|
||||||
|
// row would lead; because it is title-asc the "aaa-second" row must lead —
|
||||||
|
// and it must lead in BOTH ASC and DESC date directions, since the date is
|
||||||
|
// equal so only the title tiebreaker decides.
|
||||||
|
//
|
||||||
|
// The Sort under test is built by the PRODUCTION resolveSort(DATE, dir) (via
|
||||||
|
// reflection — it is private), not hand-rolled here, so this test proves the
|
||||||
|
// real Postgres ordering that production emits, on real same-date rows.
|
||||||
|
documentRepository.deleteAll();
|
||||||
|
LocalDate sameDate = LocalDate.of(1920, 3, 3);
|
||||||
|
save("zzz-first", sameDate); // saved first → earlier createdAt
|
||||||
|
save("aaa-second", sameDate); // saved second → later createdAt
|
||||||
|
|
||||||
|
List<Document> asc = documentRepository.findAll(resolveProductionSort("ASC"));
|
||||||
|
assertThat(asc).extracting(Document::getTitle)
|
||||||
|
.containsExactly("aaa-second", "zzz-first");
|
||||||
|
|
||||||
|
List<Document> desc = documentRepository.findAll(resolveProductionSort("DESC"));
|
||||||
|
assertThat(desc).extracting(Document::getTitle)
|
||||||
|
.containsExactly("aaa-second", "zzz-first");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invokes the production {@link DocumentService#resolveSort(DocumentSort, String)}
|
||||||
|
* for the DATE sort so the integration assertions exercise the real tiebreaker
|
||||||
|
* choice rather than a sort hand-built in the test.
|
||||||
|
*/
|
||||||
|
private Sort resolveProductionSort(String dir) throws Exception {
|
||||||
|
// resolveSort is a pure function of its arguments (uses no instance state), so a
|
||||||
|
// bean instance with null collaborators is sufficient to exercise it.
|
||||||
|
var ctor = DocumentService.class.getDeclaredConstructors()[0];
|
||||||
|
ctor.setAccessible(true);
|
||||||
|
Object[] args = new Object[ctor.getParameterCount()];
|
||||||
|
DocumentService service = (DocumentService) ctor.newInstance(args);
|
||||||
|
var m = DocumentService.class.getDeclaredMethod("resolveSort", DocumentSort.class, String.class);
|
||||||
|
m.setAccessible(true);
|
||||||
|
return (Sort) m.invoke(service, DocumentSort.DATE, dir);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void undatedOnly_returnsExactlyTheNullDatedRows() {
|
void undatedOnly_returnsExactlyTheNullDatedRows() {
|
||||||
List<Document> result = documentRepository.findAll(undatedOnly(true));
|
List<Document> result = documentRepository.findAll(undatedOnly(true));
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,12 +140,12 @@ class CanonicalImportIntegrationTest {
|
|||||||
|
|
||||||
// Re-stage the document sheet with W-0001's receiver and tag removed.
|
// Re-stage the document sheet with W-0001's receiver and tag removed.
|
||||||
writeSheet(artifactDir.resolve("canonical-documents.xlsx"),
|
writeSheet(artifactDir.resolve("canonical-documents.xlsx"),
|
||||||
List.of("index", "file", "sender_person_id", "sender_name", "receiver_person_ids",
|
List.of("index", "sender_person_id", "sender_name", "receiver_person_ids",
|
||||||
"receiver_names", "date_iso", "date_raw", "date_precision", "date_end", "location", "tags", "summary"),
|
"receiver_names", "date_iso", "date_raw", "date_precision", "date_end", "location", "tags", "summary"),
|
||||||
List.of(
|
List.of(
|
||||||
List.of("W-0001", "", "de-gruyter-walter", "Walter de Gruyter",
|
List.of("W-0001", "de-gruyter-walter", "Walter de Gruyter",
|
||||||
"", "", "1888-02-15", "15.2.1888", "DAY", "", "Rotterdam", "", "Geschäftsreise"),
|
"", "", "1888-02-15", "15.2.1888", "DAY", "", "Rotterdam", "", "Geschäftsreise"),
|
||||||
List.of("W-0002", "", "de-gruyter-eugenie", "Eugenie de Gruyter",
|
List.of("W-0002", "de-gruyter-eugenie", "Eugenie de Gruyter",
|
||||||
"de-gruyter-walter", "Walter de Gruyter", "1888-02-16", "16.2.1888", "DAY", "",
|
"de-gruyter-walter", "Walter de Gruyter", "1888-02-16", "16.2.1888", "DAY", "",
|
||||||
"Middelburg", "Themen/Brautbriefe", "Reisepläne")));
|
"Middelburg", "Themen/Brautbriefe", "Reisepläne")));
|
||||||
|
|
||||||
@@ -196,13 +196,13 @@ class CanonicalImportIntegrationTest {
|
|||||||
""");
|
""");
|
||||||
|
|
||||||
writeSheet(dir.resolve("canonical-documents.xlsx"),
|
writeSheet(dir.resolve("canonical-documents.xlsx"),
|
||||||
List.of("index", "file", "sender_person_id", "sender_name", "receiver_person_ids",
|
List.of("index", "sender_person_id", "sender_name", "receiver_person_ids",
|
||||||
"receiver_names", "date_iso", "date_raw", "date_precision", "date_end", "location", "tags", "summary"),
|
"receiver_names", "date_iso", "date_raw", "date_precision", "date_end", "location", "tags", "summary"),
|
||||||
List.of(
|
List.of(
|
||||||
List.of("W-0001", "", "de-gruyter-walter", "Walter de Gruyter",
|
List.of("W-0001", "de-gruyter-walter", "Walter de Gruyter",
|
||||||
"de-gruyter-eugenie", "Eugenie de Gruyter", "1888-02-15", "15.2.1888", "DAY", "",
|
"de-gruyter-eugenie", "Eugenie de Gruyter", "1888-02-15", "15.2.1888", "DAY", "",
|
||||||
"Rotterdam", "Themen/Brautbriefe", "Geschäftsreise"),
|
"Rotterdam", "Themen/Brautbriefe", "Geschäftsreise"),
|
||||||
List.of("W-0002", "", "de-gruyter-eugenie", "Eugenie de Gruyter",
|
List.of("W-0002", "de-gruyter-eugenie", "Eugenie de Gruyter",
|
||||||
"de-gruyter-walter", "Walter de Gruyter", "1888-02-16", "16.2.1888", "DAY", "",
|
"de-gruyter-walter", "Walter de Gruyter", "1888-02-16", "16.2.1888", "DAY", "",
|
||||||
"Middelburg", "Themen/Brautbriefe", "Reisepläne")));
|
"Middelburg", "Themen/Brautbriefe", "Reisepläne")));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ import org.mockito.InOrder;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.NetworkDTO;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.PersonNodeDTO;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.RelationshipDTO;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
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;
|
||||||
@@ -29,10 +35,12 @@ class CanonicalImportOrchestratorTest {
|
|||||||
@Mock PersonRegisterImporter personRegisterImporter;
|
@Mock PersonRegisterImporter personRegisterImporter;
|
||||||
@Mock PersonTreeImporter personTreeImporter;
|
@Mock PersonTreeImporter personTreeImporter;
|
||||||
@Mock DocumentImporter documentImporter;
|
@Mock DocumentImporter documentImporter;
|
||||||
|
@Mock RelationshipService relationshipService;
|
||||||
|
|
||||||
private CanonicalImportOrchestrator orchestrator(Path dir) {
|
private CanonicalImportOrchestrator orchestrator(Path dir) {
|
||||||
CanonicalImportOrchestrator o = new CanonicalImportOrchestrator(
|
CanonicalImportOrchestrator o = new CanonicalImportOrchestrator(
|
||||||
tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter);
|
tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter,
|
||||||
|
relationshipService);
|
||||||
ReflectionTestUtils.setField(o, "canonicalDir", dir.toString());
|
ReflectionTestUtils.setField(o, "canonicalDir", dir.toString());
|
||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
@@ -53,6 +61,7 @@ class CanonicalImportOrchestratorTest {
|
|||||||
void runImport_loadsTagsAndPersonsBeforeDocuments(@TempDir Path dir) throws Exception {
|
void runImport_loadsTagsAndPersonsBeforeDocuments(@TempDir Path dir) throws Exception {
|
||||||
writeAllArtifacts(dir);
|
writeAllArtifacts(dir);
|
||||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
||||||
|
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
|
||||||
CanonicalImportOrchestrator o = orchestrator(dir);
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
o.runImport();
|
o.runImport();
|
||||||
@@ -68,6 +77,7 @@ class CanonicalImportOrchestratorTest {
|
|||||||
void runImport_setsStatusDone_onSuccess(@TempDir Path dir) throws Exception {
|
void runImport_setsStatusDone_onSuccess(@TempDir Path dir) throws Exception {
|
||||||
writeAllArtifacts(dir);
|
writeAllArtifacts(dir);
|
||||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(3, List.of()));
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(3, List.of()));
|
||||||
|
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
|
||||||
CanonicalImportOrchestrator o = orchestrator(dir);
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
o.runImport();
|
o.runImport();
|
||||||
@@ -118,6 +128,7 @@ class CanonicalImportOrchestratorTest {
|
|||||||
writeAllArtifacts(dir);
|
writeAllArtifacts(dir);
|
||||||
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(1,
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(1,
|
||||||
List.of(new ImportStatus.SkippedFile("fake.pdf", ImportStatus.SkipReason.INVALID_PDF_SIGNATURE))));
|
List.of(new ImportStatus.SkippedFile("fake.pdf", ImportStatus.SkipReason.INVALID_PDF_SIGNATURE))));
|
||||||
|
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
|
||||||
CanonicalImportOrchestrator o = orchestrator(dir);
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
o.runImport();
|
o.runImport();
|
||||||
@@ -127,4 +138,46 @@ class CanonicalImportOrchestratorTest {
|
|||||||
.extracting(ImportStatus.SkippedFile::filename)
|
.extracting(ImportStatus.SkippedFile::filename)
|
||||||
.containsExactly("fake.pdf");
|
.containsExactly("fake.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── generation monotonicity soft-check (#689) ─────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImport_invokesGetFamilyNetwork_afterPersonLoaders_beforeDocuments(@TempDir Path dir) throws Exception {
|
||||||
|
writeAllArtifacts(dir);
|
||||||
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
||||||
|
when(relationshipService.getFamilyNetwork()).thenReturn(new NetworkDTO(List.of(), List.of()));
|
||||||
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
|
o.runImport();
|
||||||
|
|
||||||
|
InOrder order = inOrder(personRegisterImporter, personTreeImporter, relationshipService, documentImporter);
|
||||||
|
order.verify(personRegisterImporter).load(any());
|
||||||
|
order.verify(personTreeImporter).load(any());
|
||||||
|
order.verify(relationshipService).getFamilyNetwork();
|
||||||
|
order.verify(documentImporter).load(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImport_completes_evenWhenMonotonicityViolatingEdgePresent(@TempDir Path dir) throws Exception {
|
||||||
|
// child.generation (2) <= parent.generation (3) — monotonicity violation.
|
||||||
|
// The orchestrator must WARN and continue; it must not abort or fail-closed.
|
||||||
|
writeAllArtifacts(dir);
|
||||||
|
UUID parentId = UUID.randomUUID();
|
||||||
|
UUID childId = UUID.randomUUID();
|
||||||
|
PersonNodeDTO parent = new PersonNodeDTO(parentId, "Parent", null, null, 3, true);
|
||||||
|
PersonNodeDTO child = new PersonNodeDTO(childId, "Child", null, null, 2, true);
|
||||||
|
RelationshipDTO edge = new RelationshipDTO(
|
||||||
|
UUID.randomUUID(), parentId, childId,
|
||||||
|
"Parent", null, null, "Child", null, null,
|
||||||
|
RelationType.PARENT_OF, null, null, null);
|
||||||
|
when(relationshipService.getFamilyNetwork())
|
||||||
|
.thenReturn(new NetworkDTO(List.of(parent, child), List.of(edge)));
|
||||||
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
||||||
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
|
o.runImport();
|
||||||
|
|
||||||
|
assertThat(o.getStatus().state()).isEqualTo(ImportStatus.State.DONE);
|
||||||
|
verify(documentImporter).load(any());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
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.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
@@ -49,108 +50,196 @@ class DocumentImporterTest {
|
|||||||
@Mock TagService tagService;
|
@Mock TagService tagService;
|
||||||
@Mock S3Client s3Client;
|
@Mock S3Client s3Client;
|
||||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||||
|
@Mock FileStreamOpener fileStreamOpener;
|
||||||
|
|
||||||
DocumentImporter importer;
|
DocumentImporter importer;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() throws java.io.IOException {
|
||||||
importer = new DocumentImporter(documentService, personService, tagService, s3Client, thumbnailAsyncRunner);
|
// Default opener delegates to FileInputStream — tests that need to force an IOException
|
||||||
|
// override this stub locally (load_skipsFile_whenMagicByteCheckThrowsIoException).
|
||||||
|
lenient().when(fileStreamOpener.open(any(File.class)))
|
||||||
|
.thenAnswer(inv -> new java.io.FileInputStream(inv.getArgument(0, File.class)));
|
||||||
|
// Real factory (pure, dependency-free) so the title-content assertions below exercise
|
||||||
|
// the shared composition rather than a stub — the #726 single source of truth.
|
||||||
|
importer = new DocumentImporter(documentService, new DocumentTitleFactory(), personService,
|
||||||
|
tagService, s3Client, thumbnailAsyncRunner, fileStreamOpener);
|
||||||
ReflectionTestUtils.setField(importer, "bucketName", "test-bucket");
|
ReflectionTestUtils.setField(importer, "bucketName", "test-bucket");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── security regression — ported from MassImportServiceTest — do not remove ─────
|
// ─── index validation — a malicious/garbage index can never reach disk I/O ─────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsFalse_whenNull() {
|
void isValidImportIndex_returnsFalse_whenNull() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", (String) null)).isFalse();
|
assertThat(validIndex(null)).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsFalse_whenBlank() {
|
void isValidImportIndex_returnsFalse_whenBlank() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", " ")).isFalse();
|
assertThat(validIndex(" ")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsFalse_whenForwardSlash() {
|
void isValidImportIndex_returnsFalse_whenForwardSlash() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "etc/passwd")).isFalse();
|
assertThat(validIndex("etc/passwd")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsFalse_whenBackslash() {
|
void isValidImportIndex_returnsFalse_whenBackslash() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "..\\etc\\passwd")).isFalse();
|
assertThat(validIndex("..\\etc\\passwd")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsFalse_whenDotDot() {
|
void isValidImportIndex_returnsFalse_whenDotDot() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "doc..evil.pdf")).isFalse();
|
assertThat(validIndex("W-..0001")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsFalse_whenIsDotDot() {
|
void isValidImportIndex_returnsFalse_whenIsDotDot() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "..")).isFalse();
|
assertThat(validIndex("..")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsFalse_whenAbsolutePath() {
|
void isValidImportIndex_returnsFalse_whenSingleDot() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "/etc/passwd")).isFalse();
|
assertThat(validIndex(".")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsFalse_whenNullByte() {
|
void isValidImportIndex_returnsFalse_whenAbsolutePath() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "file\0.pdf")).isFalse();
|
assertThat(validIndex("/etc/passwd")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsFalse_whenUnicodeDivisionSlash() {
|
void isValidImportIndex_returnsFalse_whenNullByte() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "foo∕bar.pdf")).isFalse();
|
assertThat(validIndex("W-0001\0")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsFalse_whenFullwidthSlash() {
|
void isValidImportIndex_returnsFalse_whenUnicodeDivisionSlash() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "foo/bar.pdf")).isFalse();
|
assertThat(validIndex("W∕0001")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsFalse_whenReverseSolidusOperator() {
|
void isValidImportIndex_returnsFalse_whenFullwidthSlash() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "foo⧵bar.pdf")).isFalse();
|
assertThat(validIndex("W/0001")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsTrue_whenPlainBasename() {
|
void isValidImportIndex_returnsFalse_whenReverseSolidusOperator() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "document.pdf")).isTrue();
|
assertThat(validIndex("W⧵0001")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsTrue_whenLeadingDot() {
|
void isValidImportIndex_returnsFalse_whenContainsDotPdfExtension() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", ".hidden.pdf")).isTrue();
|
// The index is the bare catalog id; appending ".pdf" is the importer's job. A dot in
|
||||||
|
// the index would let "W-0001.pdf" become "W-0001.pdf.pdf" or smuggle an extension.
|
||||||
|
assertThat(validIndex("W-0001.pdf")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── catalog-shape rejects — pass the char pre-checks but must fail INDEX_PATTERN ────
|
||||||
|
// These pin the regex branch itself: each string contains no separator, dot, slash
|
||||||
|
// homoglyph, null byte, or absolute marker, so it sails past every char guard and is
|
||||||
|
// rejected *only* because INDEX_PATTERN.matches() returns false. A weaker pattern would
|
||||||
|
// let them through — these tests would then go red.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportIndex_returnsFalse_whenSpaceInIndex() {
|
||||||
|
// The real-world reject: "J 0070" is a space-typo with no PDF on disk.
|
||||||
|
assertThat(validIndex("J 0070")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void isValidImportFilename_returnsTrue_whenHasSpaces() {
|
void isValidImportIndex_returnsFalse_whenFiveLetterPrefix() {
|
||||||
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "Brief an Oma.pdf")).isTrue();
|
// The catalog prefix is at most 4 letters; 5 must not match.
|
||||||
|
assertThat(validIndex("WXYZA-0001")).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findFileRecursive_throwsDomainException_whenSymlinkEscapesImportDir(
|
void isValidImportIndex_returnsFalse_whenNoLetterPrefix() {
|
||||||
@TempDir Path importDirPath, @TempDir Path outsideDir) throws Exception {
|
// A digit-led id (no letter prefix) is not a catalog shape.
|
||||||
Path outsideFile = outsideDir.resolve("secret.pdf");
|
assertThat(validIndex("12-0001")).isFalse();
|
||||||
Files.writeString(outsideFile, "sensitive");
|
|
||||||
Files.createSymbolicLink(importDirPath.resolve("secret.pdf"), outsideFile);
|
|
||||||
ReflectionTestUtils.setField(importer, "importDir", importDirPath.toString());
|
|
||||||
|
|
||||||
org.assertj.core.api.Assertions.assertThatThrownBy(
|
|
||||||
() -> ReflectionTestUtils.invokeMethod(importer, "findFileRecursive", "secret.pdf"))
|
|
||||||
.isInstanceOf(org.raddatz.familienarchiv.exception.DomainException.class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── path traversal in the file column cannot escape importDir ───────────────────
|
@Test
|
||||||
|
void isValidImportIndex_returnsFalse_whenUppercaseXSuffix() {
|
||||||
|
// Only a lowercase trailing "x" is allowed; an uppercase "X" suffix must fail.
|
||||||
|
assertThat(validIndex("W-0001X")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void load_rejectsFileColumn_whenBasenameIsTraversalToken(@TempDir Path tempDir) throws Exception {
|
void isValidImportIndex_returnsTrue_whenPlainCatalogIndex() {
|
||||||
// A file column whose basename is itself a traversal token must be rejected
|
assertThat(validIndex("W-0124")).isTrue();
|
||||||
// outright, never used for disk I/O.
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportIndex_returnsTrue_whenTwoLetterPrefix() {
|
||||||
|
assertThat(validIndex("Al-0001")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportIndex_returnsTrue_whenThreeLetterPrefix() {
|
||||||
|
assertThat(validIndex("CuH-0010")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportIndex_returnsTrue_whenUmlautPrefix() {
|
||||||
|
// Real corpus indices carry a German umlaut, e.g. "Mü-0001.pdf" exists on disk.
|
||||||
|
assertThat(validIndex("Mü-0001")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportIndex_returnsTrue_whenDoubleHyphen() {
|
||||||
|
// Real corpus: "C--0029" appears in the spreadsheet (a data-entry artefact, but a
|
||||||
|
// legitimate catalog shape that must still resolve, not crash).
|
||||||
|
assertThat(validIndex("C--0029")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportIndex_returnsTrue_whenXSuffix() {
|
||||||
|
// The normalizer recognises an x-suffix catalog id; allow it defensively.
|
||||||
|
assertThat(validIndex("W-0001x")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── a valid index resolves to exactly importDir/<index>.pdf within containment ─────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_resolvesPdfByIndex_uploadsToS3_andSetsStatusUploaded(@TempDir Path tempDir) throws Exception {
|
||||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0001", "evil/..", "", "", "", "", "", "", "", ""));
|
byte[] pdf = {0x25, 0x50, 0x44, 0x46, 0x2D};
|
||||||
|
Files.write(tempDir.resolve("W-0124.pdf"), pdf);
|
||||||
|
when(documentService.findByOriginalFilename("W-0124")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0124", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
// exactly importDir/<index>.pdf was uploaded — the S3 key carries that basename
|
||||||
|
org.mockito.ArgumentCaptor<RequestBody> bodyCaptor = org.mockito.ArgumentCaptor.forClass(RequestBody.class);
|
||||||
|
verify(s3Client).putObject(any(PutObjectRequest.class), bodyCaptor.capture());
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||||
|
d.getStatus() == DocumentStatus.UPLOADED
|
||||||
|
&& d.getFilePath() != null
|
||||||
|
&& d.getFilePath().endsWith("_W-0124.pdf")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_yieldsPlaceholder_whenIndexedPdfMissing(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename("X-9999")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("X-9999", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getStatus() == DocumentStatus.PLACEHOLDER));
|
||||||
|
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_rejectsMaliciousIndex_neverReadsOutsideImportDir(@TempDir Path tempDir) throws Exception {
|
||||||
|
// An index with a path separator must be skipped outright, never used for disk I/O.
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("../../etc/cron.d/x", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
DocumentImporter.LoadResult result = importer.load(xlsx.toFile());
|
DocumentImporter.LoadResult result = importer.load(xlsx.toFile());
|
||||||
|
|
||||||
@@ -158,24 +247,49 @@ class DocumentImporterTest {
|
|||||||
.extracting(ImportStatus.SkippedFile::reason)
|
.extracting(ImportStatus.SkippedFile::reason)
|
||||||
.containsExactly(ImportStatus.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
|
.containsExactly(ImportStatus.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
|
||||||
verify(documentService, never()).save(any());
|
verify(documentService, never()).save(any());
|
||||||
|
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void load_traversalFileColumn_cannotEscapeImportDir_yieldsPlaceholder(@TempDir Path tempDir) throws Exception {
|
void resolvePdfByIndex_throwsWhenResolvedPathEscapesImportDir_viaSymlink(
|
||||||
// ../../etc/cron.d/x reduces to basename "x"; the disk lookup is confined to
|
@TempDir Path importDirPath, @TempDir Path outsideDir) throws Exception {
|
||||||
// importDir, so no file is found, nothing is uploaded, and the row becomes a
|
// Containment defense-in-depth: even a syntactically valid index whose <index>.pdf is a
|
||||||
// metadata-only PLACEHOLDER — the file outside importDir is never read.
|
// symlink pointing outside importDir must be refused — the resolved canonical path is
|
||||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
// asserted to stay inside importDir.
|
||||||
when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.empty());
|
Path outsideFile = outsideDir.resolve("secret.pdf");
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
Files.writeString(outsideFile, "sensitive");
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0001", "../../etc/cron.d/x", "", "", "", "", "", "", "", ""));
|
Files.createSymbolicLink(importDirPath.resolve("W-0001.pdf"), outsideFile);
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", importDirPath.toString());
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
org.assertj.core.api.Assertions.assertThatThrownBy(
|
||||||
|
() -> ReflectionTestUtils.invokeMethod(importer, "resolvePdfByIndex", "W-0001", 2))
|
||||||
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
.isInstanceOf(org.raddatz.familienarchiv.exception.DomainException.class);
|
||||||
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getStatus() == DocumentStatus.PLACEHOLDER));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvePdfByIndex_returnsExactlyImportDirIndexPdf_whenPresent(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Path expected = tempDir.resolve("Eu-0628.pdf");
|
||||||
|
Files.writeString(expected, "%PDF-1.4");
|
||||||
|
|
||||||
|
Optional<File> resolved = ReflectionTestUtils.invokeMethod(importer, "resolvePdfByIndex", "Eu-0628", 2);
|
||||||
|
|
||||||
|
assertThat(resolved).isPresent();
|
||||||
|
assertThat(resolved.get().getCanonicalFile()).isEqualTo(expected.toFile().getCanonicalFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE (Sara, PR #687): the IOException branch of resolvePdfByIndex — where
|
||||||
|
// File.getCanonicalPath() itself throws (an OS-level failure mid-resolution, not the
|
||||||
|
// symlink-escape DomainException) — is intentionally NOT covered by a test. Unlike
|
||||||
|
// isPdfMagicBytes, which has the package-private openFileStream(File) seam a Mockito spy can
|
||||||
|
// make throw, getCanonicalPath() is called on a File built internally with no injection seam,
|
||||||
|
// and there is no portable, deterministic way to make it throw on a temp file (it does not
|
||||||
|
// throw for missing/symlinked paths — those are handled by isFile()/the containment check).
|
||||||
|
// Adding a seam purely to test this would be production code in service of a non-defect; the
|
||||||
|
// substantive fix is the log.warn() now emitted in that branch so the quiet skip surfaces in
|
||||||
|
// ops. Left uncovered by deliberate decision, documented here so the branch is not assumed
|
||||||
|
// tested.
|
||||||
|
|
||||||
// ─── PDF magic-byte guard — ported — do not remove ──────────────────────────────
|
// ─── PDF magic-byte guard — ported — do not remove ──────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -183,7 +297,7 @@ class DocumentImporterTest {
|
|||||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
Files.writeString(tempDir.resolve("W-0001.pdf"), "not a pdf");
|
Files.writeString(tempDir.resolve("W-0001.pdf"), "not a pdf");
|
||||||
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0001", "..\\__scan\\W-0001.pdf", "", "", "", "", "", "", "", ""));
|
Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
DocumentImporter.LoadResult result = importer.load(xlsx.toFile());
|
DocumentImporter.LoadResult result = importer.load(xlsx.toFile());
|
||||||
|
|
||||||
@@ -198,13 +312,13 @@ class DocumentImporterTest {
|
|||||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
Files.writeString(tempDir.resolve("W-0001.pdf"), "content");
|
Files.writeString(tempDir.resolve("W-0001.pdf"), "content");
|
||||||
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0001", "..\\__scan\\W-0001.pdf", "", "", "", "", "", "", "", ""));
|
Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
DocumentImporter spyImporter = org.mockito.Mockito.spy(importer);
|
// FileStreamOpener is injected — stub it to throw, no spy on the importer needed.
|
||||||
org.mockito.Mockito.doThrow(new java.io.IOException("read error"))
|
org.mockito.Mockito.when(fileStreamOpener.open(any(File.class)))
|
||||||
.when(spyImporter).openFileStream(any(File.class));
|
.thenThrow(new java.io.IOException("read error"));
|
||||||
|
|
||||||
DocumentImporter.LoadResult result = spyImporter.load(xlsx.toFile());
|
DocumentImporter.LoadResult result = importer.load(xlsx.toFile());
|
||||||
|
|
||||||
assertThat(result.skippedFiles())
|
assertThat(result.skippedFiles())
|
||||||
.extracting(ImportStatus.SkippedFile::reason)
|
.extracting(ImportStatus.SkippedFile::reason)
|
||||||
@@ -217,7 +331,7 @@ class DocumentImporterTest {
|
|||||||
Document existing = Document.builder().id(UUID.randomUUID())
|
Document existing = Document.builder().id(UUID.randomUUID())
|
||||||
.originalFilename("W-0001").status(DocumentStatus.UPLOADED).build();
|
.originalFilename("W-0001").status(DocumentStatus.UPLOADED).build();
|
||||||
when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.of(existing));
|
when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.of(existing));
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", "", ""));
|
Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
DocumentImporter.LoadResult result = importer.load(xlsx.toFile());
|
DocumentImporter.LoadResult result = importer.load(xlsx.toFile());
|
||||||
|
|
||||||
@@ -227,29 +341,14 @@ class DocumentImporterTest {
|
|||||||
verify(documentService, never()).save(any());
|
verify(documentService, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── file column drives status: present → UPLOADED, empty → PLACEHOLDER ───────────
|
// ─── presence of importDir/<index>.pdf drives status: present → UPLOADED, absent → PLACEHOLDER ─
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void load_uploadsToS3_andSetsStatusUploaded_whenFilePresent(@TempDir Path tempDir) throws Exception {
|
void load_setsStatusPlaceholder_whenNoIndexedPdf(@TempDir Path tempDir) throws Exception {
|
||||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
|
||||||
byte[] pdf = {0x25, 0x50, 0x44, 0x46, 0x2D};
|
|
||||||
Files.write(tempDir.resolve("W-0001.pdf"), pdf);
|
|
||||||
when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0001", "..\\__scan\\W-0001.pdf", "", "", "", "", "", "", "", ""));
|
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
|
||||||
|
|
||||||
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
|
||||||
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getStatus() == DocumentStatus.UPLOADED));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void load_setsStatusPlaceholder_whenFileColumnEmpty(@TempDir Path tempDir) throws Exception {
|
|
||||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
when(documentService.findByOriginalFilename("W-0099")).thenReturn(Optional.empty());
|
when(documentService.findByOriginalFilename("W-0099")).thenReturn(Optional.empty());
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0099", "", "", "", "", "", "", "", "", ""));
|
Path xlsx = writeDocs(tempDir, docRow("W-0099", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
@@ -267,7 +366,7 @@ class DocumentImporterTest {
|
|||||||
when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.empty());
|
when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.empty());
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
when(personService.findBySourceRef("de-gruyter-walter")).thenReturn(Optional.of(walter));
|
when(personService.findBySourceRef("de-gruyter-walter")).thenReturn(Optional.of(walter));
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "de-gruyter-walter", "Walter de Gruyter",
|
Path xlsx = writeDocs(tempDir, docRow("W-0001", "de-gruyter-walter", "Walter de Gruyter",
|
||||||
"", "", "", "", "", ""));
|
"", "", "", "", "", ""));
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
importer.load(xlsx.toFile());
|
||||||
@@ -285,7 +384,7 @@ class DocumentImporterTest {
|
|||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
when(personService.findBySourceRef("schwester-hanni")).thenReturn(Optional.empty());
|
when(personService.findBySourceRef("schwester-hanni")).thenReturn(Optional.empty());
|
||||||
when(personService.upsertBySourceRef(any())).thenReturn(provisional);
|
when(personService.upsertBySourceRef(any())).thenReturn(provisional);
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0002", "", "schwester-hanni", "Schwester Hanni",
|
Path xlsx = writeDocs(tempDir, docRow("W-0002", "schwester-hanni", "Schwester Hanni",
|
||||||
"", "", "", "", "", ""));
|
"", "", "", "", "", ""));
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
importer.load(xlsx.toFile());
|
||||||
@@ -302,7 +401,7 @@ class DocumentImporterTest {
|
|||||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
when(documentService.findByOriginalFilename("W-0003")).thenReturn(Optional.empty());
|
when(documentService.findByOriginalFilename("W-0003")).thenReturn(Optional.empty());
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0003", "", "", "?",
|
Path xlsx = writeDocs(tempDir, docRow("W-0003", "", "?",
|
||||||
"", "", "", "", "", ""));
|
"", "", "", "", "", ""));
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
importer.load(xlsx.toFile());
|
||||||
@@ -322,7 +421,7 @@ class DocumentImporterTest {
|
|||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
when(personService.findBySourceRef("cram-herbert")).thenReturn(Optional.of(herbert));
|
when(personService.findBySourceRef("cram-herbert")).thenReturn(Optional.of(herbert));
|
||||||
when(personService.findBySourceRef("clara")).thenReturn(Optional.of(clara));
|
when(personService.findBySourceRef("clara")).thenReturn(Optional.of(clara));
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0004", "", "", "",
|
Path xlsx = writeDocs(tempDir, docRow("W-0004", "", "",
|
||||||
"cram-herbert|clara", "Herbert Cram|Clara", "", "", "", ""));
|
"cram-herbert|clara", "Herbert Cram|Clara", "", "", "", ""));
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
importer.load(xlsx.toFile());
|
||||||
@@ -334,6 +433,60 @@ class DocumentImporterTest {
|
|||||||
&& "Herbert Cram|Clara".equals(d.getReceiverText())));
|
&& "Herbert Cram|Clara".equals(d.getReceiverText())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_provisionalReceiverUsesHumanNameFromReceiverNames_notSlug(@TempDir Path tempDir) throws Exception {
|
||||||
|
// Regression: resolveReceivers used to pass the slug as both `sourceRef` AND `lastName`,
|
||||||
|
// so an unresolved receiver "smith-john" became a provisional Person with
|
||||||
|
// lastName="smith-john". The fix consumes the parallel `receiver_names` column.
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Person provisional = Person.builder().id(UUID.randomUUID()).sourceRef("smith-john")
|
||||||
|
.lastName("John Smith").provisional(true).build();
|
||||||
|
when(documentService.findByOriginalFilename("W-0050")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.findBySourceRef("smith-john")).thenReturn(Optional.empty());
|
||||||
|
when(personService.upsertBySourceRef(any())).thenReturn(provisional);
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0050", "", "",
|
||||||
|
"smith-john", "John Smith", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<PersonUpsertCommand> captor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService).upsertBySourceRef(captor.capture());
|
||||||
|
assertThat(captor.getValue().sourceRef()).isEqualTo("smith-john");
|
||||||
|
assertThat(captor.getValue().lastName()).isEqualTo("John Smith");
|
||||||
|
assertThat(captor.getValue().provisional()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_provisionalReceiverFallsBackToSlug_whenNamesListShorterThanSlugs(@TempDir Path tempDir) throws Exception {
|
||||||
|
// Parallel-list zip: if the names list is shorter than the slugs list, slugs without a
|
||||||
|
// matching name fall back to slug as the display name. This is the "missing name" case
|
||||||
|
// (rare in canonical data but the contract must define it).
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Person alice = Person.builder().id(UUID.randomUUID()).sourceRef("alice-jones")
|
||||||
|
.lastName("Alice Jones").provisional(true).build();
|
||||||
|
Person bob = Person.builder().id(UUID.randomUUID()).sourceRef("bob-roe")
|
||||||
|
.lastName("bob-roe").provisional(true).build();
|
||||||
|
when(documentService.findByOriginalFilename("W-0051")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.findBySourceRef("alice-jones")).thenReturn(Optional.empty());
|
||||||
|
when(personService.findBySourceRef("bob-roe")).thenReturn(Optional.empty());
|
||||||
|
when(personService.upsertBySourceRef(any())).thenReturn(alice).thenReturn(bob);
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0051", "", "",
|
||||||
|
"alice-jones|bob-roe", "Alice Jones", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<PersonUpsertCommand> captor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService, org.mockito.Mockito.times(2)).upsertBySourceRef(captor.capture());
|
||||||
|
assertThat(captor.getAllValues()).extracting(PersonUpsertCommand::sourceRef)
|
||||||
|
.containsExactly("alice-jones", "bob-roe");
|
||||||
|
assertThat(captor.getAllValues()).extracting(PersonUpsertCommand::lastName)
|
||||||
|
.containsExactly("Alice Jones", "bob-roe");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── clean date values parse without semantic logic ──────────────────────────────
|
// ─── clean date values parse without semantic logic ──────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -341,7 +494,7 @@ class DocumentImporterTest {
|
|||||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
when(documentService.findByOriginalFilename("W-0005")).thenReturn(Optional.empty());
|
when(documentService.findByOriginalFilename("W-0005")).thenReturn(Optional.empty());
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0005", "", "", "",
|
Path xlsx = writeDocs(tempDir, docRow("W-0005", "", "",
|
||||||
"", "", "1916-06-01", "1.6.1916", "MONTH", ""));
|
"", "", "1916-06-01", "1.6.1916", "MONTH", ""));
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
importer.load(xlsx.toFile());
|
||||||
@@ -375,7 +528,7 @@ class DocumentImporterTest {
|
|||||||
.originalFilename("W-0007").status(DocumentStatus.PLACEHOLDER).build();
|
.originalFilename("W-0007").status(DocumentStatus.PLACEHOLDER).build();
|
||||||
when(documentService.findByOriginalFilename("W-0007")).thenReturn(Optional.of(existing));
|
when(documentService.findByOriginalFilename("W-0007")).thenReturn(Optional.of(existing));
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0007", "", "", "", "", "", "", "", "", ""));
|
Path xlsx = writeDocs(tempDir, docRow("W-0007", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
@@ -396,7 +549,7 @@ class DocumentImporterTest {
|
|||||||
when(documentService.findByOriginalFilename("W-0008")).thenReturn(Optional.of(existing));
|
when(documentService.findByOriginalFilename("W-0008")).thenReturn(Optional.of(existing));
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
// The canonical row now carries no receiver and no tag: both stale links must go.
|
// The canonical row now carries no receiver and no tag: both stale links must go.
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0008", "", "", "", "", "", "", "", "", ""));
|
Path xlsx = writeDocs(tempDir, docRow("W-0008", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
@@ -411,7 +564,7 @@ class DocumentImporterTest {
|
|||||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
when(documentService.findByOriginalFilename("W-0100")).thenReturn(Optional.empty());
|
when(documentService.findByOriginalFilename("W-0100")).thenReturn(Optional.empty());
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0100", "", "", "", "", "",
|
Path xlsx = writeDocs(tempDir, docRow("W-0100", "", "", "", "",
|
||||||
"1916-06-01", "Juni 1916", "MONTH", ""));
|
"1916-06-01", "Juni 1916", "MONTH", ""));
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
importer.load(xlsx.toFile());
|
||||||
@@ -425,7 +578,7 @@ class DocumentImporterTest {
|
|||||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
when(documentService.findByOriginalFilename("W-0101")).thenReturn(Optional.empty());
|
when(documentService.findByOriginalFilename("W-0101")).thenReturn(Optional.empty());
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0101", "", "", "", "", "",
|
Path xlsx = writeDocs(tempDir, docRow("W-0101", "", "", "", "",
|
||||||
"1943-12-24", "24.12.1943", "DAY", ""));
|
"1943-12-24", "24.12.1943", "DAY", ""));
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
importer.load(xlsx.toFile());
|
||||||
@@ -439,7 +592,7 @@ class DocumentImporterTest {
|
|||||||
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
when(documentService.findByOriginalFilename("W-0102")).thenReturn(Optional.empty());
|
when(documentService.findByOriginalFilename("W-0102")).thenReturn(Optional.empty());
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
Path xlsx = writeDocs(tempDir, docRow("W-0102", "", "", "", "", "",
|
Path xlsx = writeDocs(tempDir, docRow("W-0102", "", "", "", "",
|
||||||
"", "?", "UNKNOWN", ""));
|
"", "?", "UNKNOWN", ""));
|
||||||
|
|
||||||
importer.load(xlsx.toFile());
|
importer.load(xlsx.toFile());
|
||||||
@@ -450,12 +603,15 @@ class DocumentImporterTest {
|
|||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Map<String, String> docRow(String index, String file, String senderId, String senderName,
|
private Boolean validIndex(String index) {
|
||||||
|
return ReflectionTestUtils.invokeMethod(importer, "isValidImportIndex", index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> docRow(String index, String senderId, String senderName,
|
||||||
String receiverIds, String receiverNames, String dateIso,
|
String receiverIds, String receiverNames, String dateIso,
|
||||||
String dateRaw, String datePrecision, String dateEnd) {
|
String dateRaw, String datePrecision, String dateEnd) {
|
||||||
Map<String, String> r = new LinkedHashMap<>();
|
Map<String, String> r = new LinkedHashMap<>();
|
||||||
r.put("index", index);
|
r.put("index", index);
|
||||||
r.put("file", file);
|
|
||||||
r.put("sender_person_id", senderId);
|
r.put("sender_person_id", senderId);
|
||||||
r.put("sender_name", senderName);
|
r.put("sender_name", senderName);
|
||||||
r.put("receiver_person_ids", receiverIds);
|
r.put("receiver_person_ids", receiverIds);
|
||||||
@@ -471,7 +627,7 @@ class DocumentImporterTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, String> docRowWithTag(String index, String tagPath) {
|
private Map<String, String> docRowWithTag(String index, String tagPath) {
|
||||||
Map<String, String> r = docRow(index, "", "", "", "", "", "", "", "", "");
|
Map<String, String> r = docRow(index, "", "", "", "", "", "", "", "");
|
||||||
r.put("tags", tagPath);
|
r.put("tags", tagPath);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
@@ -479,7 +635,7 @@ class DocumentImporterTest {
|
|||||||
@SafeVarargs
|
@SafeVarargs
|
||||||
private Path writeDocs(Path dir, Map<String, String>... rows) throws Exception {
|
private Path writeDocs(Path dir, Map<String, String>... rows) throws Exception {
|
||||||
Path xlsx = dir.resolve("canonical-documents.xlsx");
|
Path xlsx = dir.resolve("canonical-documents.xlsx");
|
||||||
List<String> headers = List.of("index", "file", "sender_person_id", "sender_name",
|
List<String> headers = List.of("index", "sender_person_id", "sender_name",
|
||||||
"receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision",
|
"receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision",
|
||||||
"date_end", "location", "tags", "summary");
|
"date_end", "location", "tags", "summary");
|
||||||
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
|||||||
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.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
@@ -87,6 +89,50 @@ class PersonRegisterImporterTest {
|
|||||||
assertThat(processed).isEqualTo(2);
|
assertThat(processed).isEqualTo(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── generation parsing (#689) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(value = {
|
||||||
|
"'G 3', 3",
|
||||||
|
"'G3', 3",
|
||||||
|
"'G 3', 3",
|
||||||
|
"'3', 3",
|
||||||
|
"' 3 ', 3",
|
||||||
|
"'G 2 de Gruyter', 2",
|
||||||
|
"'', null",
|
||||||
|
"'garbage', null",
|
||||||
|
"'G 99', null",
|
||||||
|
"'G -1', null"
|
||||||
|
}, nullValues = "null")
|
||||||
|
void load_parsesGeneration_perRegex(String raw, Integer expected, @TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path xlsx = writePersonsWithGeneration(tempDir,
|
||||||
|
rowWithGeneration("herbert-cram", "Cram", "Herbert", "", "", "False", raw));
|
||||||
|
|
||||||
|
new PersonRegisterImporter(personService).load(xlsx.toFile());
|
||||||
|
|
||||||
|
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService).upsertBySourceRef(captor.capture());
|
||||||
|
assertThat(captor.getValue().generation()).isEqualTo(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_succeeds_andLeavesGenerationNull_whenArtifactHasNoGenerationColumn(@TempDir Path tempDir) throws Exception {
|
||||||
|
// REQ-IMP-001: older artifacts without the `generation` column must still
|
||||||
|
// import. REQUIRED_HEADERS is intentionally not extended.
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path xlsx = writePersons(tempDir, row(
|
||||||
|
"old-artifact", "Mueller", "Hans", "", "", "False"));
|
||||||
|
|
||||||
|
new PersonRegisterImporter(personService).load(xlsx.toFile());
|
||||||
|
|
||||||
|
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService).upsertBySourceRef(captor.capture());
|
||||||
|
assertThat(captor.getValue().generation()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
private static Person personOf(PersonUpsertCommand cmd) {
|
private static Person personOf(PersonUpsertCommand cmd) {
|
||||||
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef())
|
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef())
|
||||||
.firstName(cmd.firstName()).lastName(cmd.lastName())
|
.firstName(cmd.firstName()).lastName(cmd.lastName())
|
||||||
@@ -127,4 +173,36 @@ class PersonRegisterImporterTest {
|
|||||||
}
|
}
|
||||||
return xlsx;
|
return xlsx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, String> rowWithGeneration(String personId, String lastName, String firstName,
|
||||||
|
String maidenName, String notes, String provisional,
|
||||||
|
String generation) {
|
||||||
|
Map<String, String> r = row(personId, lastName, firstName, maidenName, notes, provisional);
|
||||||
|
r.put("generation", generation);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SafeVarargs
|
||||||
|
private Path writePersonsWithGeneration(Path dir, Map<String, String>... rows) throws Exception {
|
||||||
|
Path xlsx = dir.resolve("canonical-persons.xlsx");
|
||||||
|
List<String> headers = List.of(
|
||||||
|
"person_id", "last_name", "first_name", "maiden_name", "notes", "provisional", "generation");
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = wb.createSheet("Sheet1");
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
for (int i = 0; i < headers.size(); i++) {
|
||||||
|
header.createCell(i).setCellValue(headers.get(i));
|
||||||
|
}
|
||||||
|
for (int r = 0; r < rows.length; r++) {
|
||||||
|
Row row = sheet.createRow(r + 1);
|
||||||
|
for (int c = 0; c < headers.size(); c++) {
|
||||||
|
row.createCell(c).setCellValue(rows[r].getOrDefault(headers.get(c), ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||||
|
wb.write(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return xlsx;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,65 @@ class PersonTreeImporterTest {
|
|||||||
verify(relationshipService, org.mockito.Mockito.never()).addRelationship(any(), any());
|
verify(relationshipService, org.mockito.Mockito.never()).addRelationship(any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── generation (#689) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_passesGenerationFromJson(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_a","lastName":"Cram","firstName":"Herbert","familyMember":true,
|
||||||
|
"personId":"herbert-cram","generation":3}
|
||||||
|
],"relationships":[]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
new PersonTreeImporter(personService, relationshipService).load(json.toFile());
|
||||||
|
|
||||||
|
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService).upsertBySourceRef(captor.capture());
|
||||||
|
assertThat(captor.getValue().generation()).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_returnsNullGeneration_whenAbsentFromJson(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_a","lastName":"Cram","firstName":"Herbert","familyMember":true,
|
||||||
|
"personId":"herbert-cram"}
|
||||||
|
],"relationships":[]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
new PersonTreeImporter(personService, relationshipService).load(json.toFile());
|
||||||
|
|
||||||
|
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService).upsertBySourceRef(captor.capture());
|
||||||
|
assertThat(captor.getValue().generation()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_skipsOutOfRangeGeneration_logsWarn_neverAborts(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_a","lastName":"Cram","firstName":"Herbert","familyMember":true,
|
||||||
|
"personId":"herbert-cram","generation":99}
|
||||||
|
],"relationships":[]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
new PersonTreeImporter(personService, relationshipService).load(json.toFile());
|
||||||
|
|
||||||
|
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService).upsertBySourceRef(captor.capture());
|
||||||
|
assertThat(captor.getValue().generation()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
private static Person personOf(PersonUpsertCommand cmd) {
|
private static Person personOf(PersonUpsertCommand cmd) {
|
||||||
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef()).lastName(cmd.lastName()).build();
|
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef()).lastName(cmd.lastName()).build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -718,4 +718,74 @@ class PersonControllerTest {
|
|||||||
.content("{\"lastName\":\"de Gruyter\"}"))
|
.content("{\"lastName\":\"de Gruyter\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── generation field validation (#689) ────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updatePerson_returns400_whenGenerationAboveRange() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||||
|
+ "\"personType\":\"PERSON\",\"generation\":11}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.VALIDATION_ERROR.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updatePerson_returns400_whenGenerationBelowRange() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||||
|
+ "\"personType\":\"PERSON\",\"generation\":-1}"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.VALIDATION_ERROR.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updatePerson_returns200_whenGenerationNull() throws Exception {
|
||||||
|
// Symmetric body assertion: the response must echo generation as null (not
|
||||||
|
// absent), so the frontend re-hydrates the "(none)" option after a clear.
|
||||||
|
// Without this, the in-range test below would be the only end-to-end proof
|
||||||
|
// that the field flows through the controller.
|
||||||
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
|
when(personService.updatePerson(any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||||
|
+ "\"personType\":\"PERSON\",\"generation\":null}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.generation").value(org.hamcrest.Matchers.nullValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updatePerson_returns200_whenGenerationInRange() throws Exception {
|
||||||
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").generation(3).build();
|
||||||
|
when(personService.updatePerson(any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||||
|
+ "\"personType\":\"PERSON\",\"generation\":3}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.generation").value(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns200_whenGenerationInRange() throws Exception {
|
||||||
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").generation(3).build();
|
||||||
|
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\","
|
||||||
|
+ "\"personType\":\"PERSON\",\"generation\":3}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.generation").value(3));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,4 +148,55 @@ class PersonImportUpsertTest {
|
|||||||
|
|
||||||
assertThat(result.isProvisional()).isTrue();
|
assertThat(result.isProvisional()).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── generation (#689) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsertBySourceRef_writesGeneration_onFirstImport() {
|
||||||
|
when(personRepository.findBySourceRef("herbert-cram")).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||||
|
.sourceRef("herbert-cram").firstName("Herbert").lastName("Cram")
|
||||||
|
.generation(3).personType(PersonType.PERSON).provisional(false).build();
|
||||||
|
|
||||||
|
Person result = personService.upsertBySourceRef(cmd);
|
||||||
|
|
||||||
|
assertThat(result.getGeneration()).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsertBySourceRef_preservesHumanEditedGeneration_onReimport() {
|
||||||
|
Person humanEdited = Person.builder()
|
||||||
|
.id(UUID.randomUUID()).sourceRef("herbert-cram")
|
||||||
|
.firstName("Herbert").lastName("Cram").generation(4).build();
|
||||||
|
when(personRepository.findBySourceRef("herbert-cram")).thenReturn(Optional.of(humanEdited));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||||
|
.sourceRef("herbert-cram").firstName("Herbert").lastName("Cram")
|
||||||
|
.generation(2).personType(PersonType.PERSON).provisional(false).build();
|
||||||
|
|
||||||
|
Person result = personService.upsertBySourceRef(cmd);
|
||||||
|
|
||||||
|
assertThat(result.getGeneration()).isEqualTo(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mergeCanonical_overwrites_human_null_with_canonical_value_documenting_known_limitation() {
|
||||||
|
// If preferHuman gains explicit-null-vs-unset semantics, delete this test (see issue #689).
|
||||||
|
Person existing = Person.builder()
|
||||||
|
.id(UUID.randomUUID()).sourceRef("herbert-cram")
|
||||||
|
.firstName("Herbert").lastName("Cram").generation(null).build();
|
||||||
|
when(personRepository.findBySourceRef("herbert-cram")).thenReturn(Optional.of(existing));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||||
|
.sourceRef("herbert-cram").firstName("Herbert").lastName("Cram")
|
||||||
|
.generation(3).personType(PersonType.PERSON).provisional(false).build();
|
||||||
|
|
||||||
|
Person result = personService.upsertBySourceRef(cmd);
|
||||||
|
|
||||||
|
assertThat(result.getGeneration()).isEqualTo(3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import jakarta.persistence.PersistenceContext;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@@ -120,37 +121,60 @@ class PersonRepositoryTest {
|
|||||||
.containsExactly("Anna", "Clara");
|
.containsExactly("Anna", "Clara");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── findByAliasIgnoreCase ────────────────────────────────────────────────
|
// ─── findByAlias (exact) / findAllByAliasIgnoreCase (case-folding siblings) ───
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findByAliasIgnoreCase_returnsMatchingPerson() {
|
void findByAlias_returnsExactCaseMatchOnly() {
|
||||||
personRepository.save(Person.builder()
|
personRepository.save(Person.builder()
|
||||||
.firstName("Karl").lastName("Brandt").alias("Opa Karl").build());
|
.firstName("Karl").lastName("Brandt").alias("Opa Karl").build());
|
||||||
|
|
||||||
Optional<Person> found = personRepository.findByAliasIgnoreCase("opa karl");
|
assertThat(personRepository.findByAlias("Opa Karl")).isPresent();
|
||||||
|
assertThat(personRepository.findByAlias("opa karl")).isEmpty(); // exact-case: a folded form does NOT match
|
||||||
assertThat(found).isPresent();
|
|
||||||
assertThat(found.get().getFirstName()).isEqualTo("Karl");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
|
void findAllByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
|
||||||
Optional<Person> found = personRepository.findByAliasIgnoreCase("nobody");
|
assertThat(personRepository.findAllByAliasIgnoreCase("nobody")).isEmpty();
|
||||||
|
|
||||||
assertThat(found).isEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── findByFirstNameIgnoreCaseAndLastNameIgnoreCase ───────────────────────
|
@Test
|
||||||
|
void findAllByAliasIgnoreCase_foldsUmlautCase_inRealPostgres() {
|
||||||
|
// Proves Postgres LOWER() folds ü the same way for both rows — a plain-ASCII probe would
|
||||||
|
// stay green even if umlaut folding regressed. Both case-colliding aliases must match.
|
||||||
|
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
|
||||||
|
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
|
||||||
|
|
||||||
|
assertThat(personRepository.findAllByAliasIgnoreCase("MÜLLER")).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findByFirstNameAndLastName (exact) / findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase ───
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findByFirstNameIgnoreCaseAndLastNameIgnoreCase_returnsMatch() {
|
void findByFirstNameAndLastName_returnsExactCaseMatchOnly() {
|
||||||
personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());
|
personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());
|
||||||
|
|
||||||
Optional<Person> found = personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(
|
assertThat(personRepository.findByFirstNameAndLastName("Maria", "Raddatz")).isPresent();
|
||||||
"maria", "raddatz");
|
assertThat(personRepository.findByFirstNameAndLastName("maria", "raddatz")).isEmpty(); // exact-case only
|
||||||
|
}
|
||||||
|
|
||||||
assertThat(found).isPresent();
|
@Test
|
||||||
assertThat(found.get().getFirstName()).isEqualTo("Maria");
|
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_foldsUmlautCase_inRealPostgres() {
|
||||||
|
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||||
|
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
|
||||||
|
|
||||||
|
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("HANS", "MÜLLER"))
|
||||||
|
.hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_nullFirstName_foldsToNoMatch() {
|
||||||
|
// Fail-closed: a last-name-only filename (null first name) must NOT widen to first_name IS
|
||||||
|
// NULL and pull in the institution/last-name-only row as a "sender". Proven on real
|
||||||
|
// Postgres because a mocked unit test cannot catch the IS NULL vs `= NULL` semantics.
|
||||||
|
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
|
||||||
|
|
||||||
|
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
|
||||||
|
.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── findCorrespondents ───────────────────────────────────────────────────
|
// ─── findCorrespondents ───────────────────────────────────────────────────
|
||||||
@@ -366,30 +390,6 @@ class PersonRepositoryTest {
|
|||||||
assertThat(result).hasSize(1);
|
assertThat(result).hasSize(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── deleteReceiverReferences ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void deleteReceiverReferences_removesPersonFromAllDocumentReceivers() {
|
|
||||||
Person toDelete = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
|
||||||
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
|
||||||
|
|
||||||
Document doc1 = documentRepository.save(Document.builder()
|
|
||||||
.title("Brief 1").originalFilename("b1.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.sender(sender).receivers(Set.of(toDelete)).build());
|
|
||||||
Document doc2 = documentRepository.save(Document.builder()
|
|
||||||
.title("Brief 2").originalFilename("b2.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.sender(sender).receivers(Set.of(toDelete)).build());
|
|
||||||
|
|
||||||
personRepository.deleteReceiverReferences(toDelete.getId());
|
|
||||||
entityManager.flush();
|
|
||||||
entityManager.clear();
|
|
||||||
|
|
||||||
assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
|
|
||||||
assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── searchByName with aliases ───────────────────────────────────────────
|
// ─── searchByName with aliases ───────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -672,4 +672,181 @@ class PersonRepositoryTest {
|
|||||||
|
|
||||||
assertThat(slice.get(0).getDocumentCount()).isEqualTo(1);
|
assertThat(slice.get(0).getDocumentCount()).isEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── generation column (#689) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void save_persistsGeneration_andFindByIdReturnsSameGeneration() {
|
||||||
|
Person person = Person.builder()
|
||||||
|
.firstName("Walter")
|
||||||
|
.lastName("Raddatz")
|
||||||
|
.generation(3)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Person saved = personRepository.save(person);
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Optional<Person> found = personRepository.findById(saved.getId());
|
||||||
|
assertThat(found).isPresent();
|
||||||
|
assertThat(found.get().getGeneration()).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void save_allowsNullGeneration_existingRowsRemainNull() {
|
||||||
|
Person person = Person.builder()
|
||||||
|
.firstName("Anonym")
|
||||||
|
.lastName("Person")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Person saved = personRepository.save(person);
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Optional<Person> found = personRepository.findById(saved.getId());
|
||||||
|
assertThat(found).isPresent();
|
||||||
|
assertThat(found.get().getGeneration()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── #684: ON DELETE integrity enforced at the database layer ──────────────
|
||||||
|
// A raw deleteById (bypassing PersonService) must keep referential integrity:
|
||||||
|
// documents.sender_id → SET NULL, document_receivers.person_id → CASCADE, and the
|
||||||
|
// transcription_block_mentioned_persons soft reference → CASCADE. These run against
|
||||||
|
// real Postgres because the FK ON DELETE behaviour never fires on H2.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteById_personSenderOfAReceiverOfB_nullsSender_dropsReceiverRow_bothDocumentsSurvive() {
|
||||||
|
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||||
|
Person bystander = personRepository.save(Person.builder().firstName("Bleibt").lastName("Hier").build());
|
||||||
|
|
||||||
|
Document sent = documentRepository.save(Document.builder()
|
||||||
|
.title("Gesendet").originalFilename("sent.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(target).build());
|
||||||
|
Document received = documentRepository.save(Document.builder()
|
||||||
|
.title("Empfangen").originalFilename("received.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(bystander)
|
||||||
|
.receivers(Set.of(target)).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
personRepository.deleteById(target.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||||
|
|
||||||
|
Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
|
||||||
|
assertThat(reloadedSent.getSender()).isNull(); // AC-1: SET NULL
|
||||||
|
|
||||||
|
Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
|
||||||
|
assertThat(reloadedReceived.getReceivers())
|
||||||
|
.noneMatch(p -> p.getId().equals(target.getId())); // AC-2: CASCADE drops the join row
|
||||||
|
|
||||||
|
// Cascade-boundary guard (Nora, non-negotiable): the cascade stops at the join/reference
|
||||||
|
// layer — both documents themselves survive. Guards against a future migration turning
|
||||||
|
// documents.sender_id SET NULL into CASCADE and destroying historical letters.
|
||||||
|
assertThat(documentRepository.findById(sent.getId())).isPresent();
|
||||||
|
assertThat(documentRepository.findById(received.getId())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteById_receiverWithCoReceiver_dropsOnlyDeletedPersonsJoinRow() {
|
||||||
|
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||||
|
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||||
|
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief").originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(sender)
|
||||||
|
.receivers(Set.of(target, coReceiver)).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
personRepository.deleteById(target.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getReceivers()).extracting(Person::getId)
|
||||||
|
.containsExactly(coReceiver.getId()); // co-receiver untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteById_personIsSenderAndReceiverOfSameDocument_documentSurvives_senderNull_receiverDropped() {
|
||||||
|
// AC-8: the trickier same-document interaction the cross-document cases don't exercise.
|
||||||
|
Person target = personRepository.save(Person.builder().firstName("Beides").lastName("Person").build());
|
||||||
|
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||||
|
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Selbstbrief").originalFilename("self.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(target)
|
||||||
|
.receivers(Set.of(target, coReceiver)).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
personRepository.deleteById(target.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getSender()).isNull();
|
||||||
|
assertThat(reloaded.getReceivers()).extracting(Person::getId)
|
||||||
|
.containsExactly(coReceiver.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives() {
|
||||||
|
// AC-3: the @-mention sidecar is a CASCADE soft reference, but the literal "@Name" lives
|
||||||
|
// in transcription_blocks.text and must stay visible as plain text after the person goes.
|
||||||
|
Person mentioned = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build());
|
||||||
|
Person survivor = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief").originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).build());
|
||||||
|
entityManager.flush();
|
||||||
|
|
||||||
|
UUID annotationId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) "
|
||||||
|
+ "VALUES (?1, ?2, 1, 0.1, 0.2, 0.3, 0.1, '#fff')")
|
||||||
|
.setParameter(1, annotationId).setParameter(2, doc.getId()).executeUpdate();
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"INSERT INTO transcription_blocks (id, annotation_id, document_id, text) VALUES (?1, ?2, ?3, ?4)")
|
||||||
|
.setParameter(1, blockId).setParameter(2, annotationId).setParameter(3, doc.getId())
|
||||||
|
.setParameter(4, "Brief an @Auguste Raddatz und @Clara Cram").executeUpdate();
|
||||||
|
// Two mention rows on the same block: the deleted person and an innocent bystander.
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
|
||||||
|
+ "VALUES (?1, ?2, ?3)")
|
||||||
|
.setParameter(1, blockId).setParameter(2, mentioned.getId())
|
||||||
|
.setParameter(3, "Auguste Raddatz").executeUpdate();
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
|
||||||
|
+ "VALUES (?1, ?2, ?3)")
|
||||||
|
.setParameter(1, blockId).setParameter(2, survivor.getId())
|
||||||
|
.setParameter(3, "Clara Cram").executeUpdate();
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
personRepository.deleteById(mentioned.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Number mentionRows = (Number) entityManager.createNativeQuery(
|
||||||
|
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
|
||||||
|
.setParameter(1, mentioned.getId()).getSingleResult();
|
||||||
|
assertThat(mentionRows.longValue()).isZero();
|
||||||
|
|
||||||
|
// The cascade is scoped to the deleted person — the bystander's mention row is untouched.
|
||||||
|
Number survivorRows = (Number) entityManager.createNativeQuery(
|
||||||
|
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
|
||||||
|
.setParameter(1, survivor.getId()).getSingleResult();
|
||||||
|
assertThat(survivorRows.longValue()).isEqualTo(1);
|
||||||
|
|
||||||
|
String text = (String) entityManager.createNativeQuery(
|
||||||
|
"SELECT text FROM transcription_blocks WHERE id = ?1")
|
||||||
|
.setParameter(1, blockId).getSingleResult();
|
||||||
|
assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.PersonType;
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
@@ -16,10 +17,13 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
import jakarta.persistence.PersistenceContext;
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@@ -33,6 +37,7 @@ class PersonServiceIntegrationTest {
|
|||||||
@Autowired PersonService personService;
|
@Autowired PersonService personService;
|
||||||
@Autowired PersonRepository personRepository;
|
@Autowired PersonRepository personRepository;
|
||||||
@Autowired DocumentRepository documentRepository;
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
@Autowired DocumentService documentService;
|
||||||
|
|
||||||
@PersistenceContext EntityManager entityManager;
|
@PersistenceContext EntityManager entityManager;
|
||||||
|
|
||||||
@@ -75,6 +80,93 @@ class PersonServiceIntegrationTest {
|
|||||||
assertThat(result.getLastName()).isEqualTo("Cram");
|
assertThat(result.getLastName()).isEqualTo("Cram");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── #731: case-colliding alias resolution against real Postgres ───────────
|
||||||
|
// The umlaut pair is mandatory — only the real DB proves Postgres LOWER() folds ü; a
|
||||||
|
// plain-ASCII test would stay green while umlaut aliases regressed.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findOrCreateByAlias_resolvesUmlautAliasCollision_toLowestId_withoutThrow() {
|
||||||
|
Person muller = personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
|
||||||
|
Person mullerLower = personRepository.save(Person.builder().lastName("müller").alias("müller").build());
|
||||||
|
UUID expected = muller.getId().compareTo(mullerLower.getId()) <= 0 ? muller.getId() : mullerLower.getId();
|
||||||
|
|
||||||
|
// No exact-case "MÜLLER" row → falls through to the case-insensitive branch with two
|
||||||
|
// candidates and must pick the lowest id, never throwing NonUniqueResultException.
|
||||||
|
Person resolved = personService.findOrCreateByAlias("MÜLLER");
|
||||||
|
|
||||||
|
assertThat(resolved.getId()).isEqualTo(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findOrCreateByAlias_umlautAliasCollision_isDeterministicAcrossCalls() {
|
||||||
|
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
|
||||||
|
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
|
||||||
|
|
||||||
|
Person first = personService.findOrCreateByAlias("MÜLLER");
|
||||||
|
Person second = personService.findOrCreateByAlias("MÜLLER");
|
||||||
|
|
||||||
|
assertThat(second.getId()).isEqualTo(first.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── #731: filename-based sender resolution against real Postgres ──────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_resolvesSender_whenFilenameNameIsUnique() throws Exception {
|
||||||
|
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||||
|
|
||||||
|
Document doc = uploadNamed("1965-03-12_Müller_Hans.pdf").document();
|
||||||
|
|
||||||
|
assertThat(doc.getSender()).isNotNull();
|
||||||
|
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_resolvesSender_onSingleCaseInsensitiveMatch() throws Exception {
|
||||||
|
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||||
|
|
||||||
|
// Filename folds to "hans müller"; the only stored person is "Hans Müller".
|
||||||
|
Document doc = uploadNamed("1965-03-12_müller_hans.pdf").document();
|
||||||
|
|
||||||
|
assertThat(doc.getSender()).isNotNull();
|
||||||
|
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_leavesSenderUnset_whenFilenameNameIsAmbiguous() throws Exception {
|
||||||
|
// Two persons collide case-insensitively; the filename casing ("HANS"/"MÜLLER") matches
|
||||||
|
// neither exactly → no exact-case winner → bail to null (never an arbitrary guess), no 500.
|
||||||
|
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||||
|
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
|
||||||
|
|
||||||
|
Document doc = uploadNamed("1965-03-12_MÜLLER_HANS.pdf").document();
|
||||||
|
|
||||||
|
assertThat(doc.getSender()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_leavesSenderUnset_whenFilenameHasNoFirstName() throws Exception {
|
||||||
|
// A last-name-only filename never resolves to a sender (the parser yields no parsed name).
|
||||||
|
personRepository.save(Person.builder().lastName("Müller").build());
|
||||||
|
|
||||||
|
Document doc = uploadNamed("1965-03-12_Müller.pdf").document();
|
||||||
|
|
||||||
|
assertThat(doc.getSender()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByName_nullFirstName_resolvesToEmpty_inRealPostgres() {
|
||||||
|
// Fail-closed against the real DB: a null first name must NOT widen to first_name IS NULL
|
||||||
|
// and pick up the last-name-only row.
|
||||||
|
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
|
||||||
|
|
||||||
|
assertThat(personService.findByName(null, "Müller")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DocumentService.StoreResult uploadNamed(String filename) throws Exception {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1, 2, 3});
|
||||||
|
return documentService.storeDocument(file, null);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── #667: confirm round-trip + reader-default semantics ──────────────────
|
// ─── #667: confirm round-trip + reader-default semantics ──────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -124,12 +216,65 @@ class PersonServiceIntegrationTest {
|
|||||||
assertThat(personRepository.findById(target.getId())).isEmpty();
|
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── generation full-stack round-trip (#689) ──────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_clearGenerationToNull_readsBackNullFromDb() {
|
||||||
|
// Sara's QA concern: pin the full PUT→DB→GET round-trip for the
|
||||||
|
// null-clear path. Without this we only have the WebMvcTest mocked
|
||||||
|
// boundary; nothing proved the JPA flush actually wrote SQL NULL.
|
||||||
|
Person seeded = personRepository.save(Person.builder()
|
||||||
|
.firstName("Hans").lastName("Raddatz")
|
||||||
|
.personType(PersonType.PERSON).generation(3).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setPersonType(PersonType.PERSON);
|
||||||
|
dto.setFirstName("Hans");
|
||||||
|
dto.setLastName("Raddatz");
|
||||||
|
dto.setGeneration(null);
|
||||||
|
|
||||||
|
personService.updatePerson(seeded.getId(), dto);
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getGeneration()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_setGenerationToZero_readsBackZeroFromDb() {
|
||||||
|
// Pin the G 0 case end-to-end. The form-action spec covers that 0
|
||||||
|
// doesn't get spread-dropped at the SvelteKit boundary; this test
|
||||||
|
// covers that the controller + service + JPA chain preserves the
|
||||||
|
// primitive zero (not coerced to null somewhere along the way).
|
||||||
|
Person seeded = personRepository.save(Person.builder()
|
||||||
|
.firstName("Walter").lastName("Raddatz")
|
||||||
|
.personType(PersonType.PERSON).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setPersonType(PersonType.PERSON);
|
||||||
|
dto.setFirstName("Walter");
|
||||||
|
dto.setLastName("Raddatz");
|
||||||
|
dto.setGeneration(0);
|
||||||
|
|
||||||
|
personService.updatePerson(seeded.getId(), dto);
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Person reloaded = personRepository.findById(seeded.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getGeneration()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
||||||
// A person referenced as BOTH a document sender and a document receiver must delete
|
// A person referenced as BOTH a document sender and a document receiver must delete
|
||||||
// cleanly: deletePerson nulls the sender_id FK and removes the receiver join row first
|
// cleanly via the service path: deletePerson just calls deleteById, and V71's ON DELETE
|
||||||
// (reassignSenderToNull → deleteReceiverReferences → deleteById), so no FK orphan and
|
// constraints null the sender_id FK and drop the receiver join row, so there is no FK
|
||||||
// the documents themselves survive.
|
// orphan and the documents themselves survive.
|
||||||
Person target = personRepository.save(Person.builder()
|
Person target = personRepository.save(Person.builder()
|
||||||
.firstName("Weg").lastName("Person").provisional(true).build());
|
.firstName("Weg").lastName("Person").provisional(true).build());
|
||||||
Person bystander = personRepository.save(Person.builder()
|
Person bystander = personRepository.save(Person.builder()
|
||||||
@@ -143,16 +288,16 @@ class PersonServiceIntegrationTest {
|
|||||||
.status(DocumentStatus.UPLOADED).sender(bystander)
|
.status(DocumentStatus.UPLOADED).sender(bystander)
|
||||||
.receivers(new java.util.HashSet<>(Set.of(target))).build());
|
.receivers(new java.util.HashSet<>(Set.of(target))).build());
|
||||||
|
|
||||||
// Persist the fixture and detach everything so the native @Modifying deletes operate on
|
// Persist the fixture and detach everything so the delete operates on the database
|
||||||
// the database directly without the persistence context holding stale references that
|
// directly without the persistence context holding stale references.
|
||||||
// would re-flush a now-deleted person as a transient association.
|
|
||||||
entityManager.flush();
|
entityManager.flush();
|
||||||
entityManager.clear();
|
entityManager.clear();
|
||||||
|
|
||||||
personService.deletePerson(target.getId());
|
personService.deletePerson(target.getId());
|
||||||
|
|
||||||
// Native @Modifying queries bypass the persistence context — clear it so the asserting
|
// The ON DELETE cascade fires beneath Hibernate — flush the delete and clear the L1
|
||||||
// reads observe the post-delete database state, not stale managed entities.
|
// cache so the asserting reads observe the post-delete database state, not stale
|
||||||
|
// managed entities still holding the dropped sender/receiver associations.
|
||||||
entityManager.flush();
|
entityManager.flush();
|
||||||
entityManager.clear();
|
entityManager.clear();
|
||||||
|
|
||||||
@@ -167,4 +312,38 @@ class PersonServiceIntegrationTest {
|
|||||||
// The other person and the documents themselves survive the delete.
|
// The other person and the documents themselves survive the delete.
|
||||||
assertThat(personRepository.findById(bystander.getId())).isPresent();
|
assertThat(personRepository.findById(bystander.getId())).isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mergePersons_targetInheritsReferences_sourceJoinRowCascadeDrops_noFkError() {
|
||||||
|
// AC-7: merging a source who is sender of A and receiver of B into a target leaves the
|
||||||
|
// target as sender of A and receiver of B, drops the source's leftover receiver row via
|
||||||
|
// V71's ON DELETE CASCADE (no explicit delete, no FK error), and co-receivers are intact.
|
||||||
|
Person source = personRepository.save(Person.builder().firstName("Anna").lastName("Alt").build());
|
||||||
|
Person target = personRepository.save(Person.builder().firstName("Anna").lastName("Neu").build());
|
||||||
|
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||||
|
|
||||||
|
Document docA = documentRepository.save(Document.builder()
|
||||||
|
.title("Von Anna").originalFilename("a.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(source).build());
|
||||||
|
Document docB = documentRepository.save(Document.builder()
|
||||||
|
.title("An Anna").originalFilename("b.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(sender)
|
||||||
|
.receivers(new java.util.HashSet<>(Set.of(source, coReceiver))).build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
personService.mergePersons(source.getId(), target.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertThat(personRepository.findById(source.getId())).isEmpty();
|
||||||
|
|
||||||
|
Document reloadedA = documentRepository.findById(docA.getId()).orElseThrow();
|
||||||
|
assertThat(reloadedA.getSender().getId()).isEqualTo(target.getId());
|
||||||
|
|
||||||
|
Document reloadedB = documentRepository.findById(docB.getId()).orElseThrow();
|
||||||
|
assertThat(reloadedB.getReceivers()).extracting(Person::getId)
|
||||||
|
.containsExactlyInAnyOrder(target.getId(), coReceiver.getId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.any;
|
|||||||
import static org.mockito.ArgumentMatchers.argThat;
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
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.verifyNoMoreInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -147,9 +148,11 @@ class PersonServiceTest {
|
|||||||
|
|
||||||
personService.deletePerson(id);
|
personService.deletePerson(id);
|
||||||
|
|
||||||
verify(personRepository).reassignSenderToNull(id);
|
// Integrity is enforced by V71's ON DELETE constraints — the service only checks
|
||||||
verify(personRepository).deleteReceiverReferences(id);
|
// existence then deletes; it no longer detaches sender/receiver references itself.
|
||||||
|
verify(personRepository).findById(id);
|
||||||
verify(personRepository).deleteById(id);
|
verify(personRepository).deleteById(id);
|
||||||
|
verifyNoMoreInteractions(personRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -261,6 +264,54 @@ class PersonServiceTest {
|
|||||||
.isEqualTo(400);
|
.isEqualTo(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_persistsGeneration() {
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Hans"); dto.setLastName("Raddatz");
|
||||||
|
dto.setPersonType(PersonType.PERSON); dto.setGeneration(3);
|
||||||
|
|
||||||
|
Person result = personService.createPerson(dto);
|
||||||
|
|
||||||
|
assertThat(result.getGeneration()).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_writesGeneration_includingExplicitNullClear() {
|
||||||
|
// The form path is the only place a human can clear generation back to null.
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person existing = Person.builder().id(id).firstName("Hans").lastName("Raddatz")
|
||||||
|
.personType(PersonType.PERSON).generation(3).build();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Hans"); dto.setLastName("Raddatz");
|
||||||
|
dto.setPersonType(PersonType.PERSON); dto.setGeneration(null);
|
||||||
|
|
||||||
|
Person result = personService.updatePerson(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getGeneration()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_writesGeneration_whenSet() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person existing = Person.builder().id(id).firstName("Hans").lastName("Raddatz")
|
||||||
|
.personType(PersonType.PERSON).build();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Hans"); dto.setLastName("Raddatz");
|
||||||
|
dto.setPersonType(PersonType.PERSON); dto.setGeneration(2);
|
||||||
|
|
||||||
|
Person result = personService.updatePerson(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getGeneration()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── updatePerson (personType) ───────────────────────────────────────────
|
// ─── updatePerson (personType) ───────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -324,14 +375,57 @@ class PersonServiceTest {
|
|||||||
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findOrCreateByAlias_returnsExisting_whenAliasFound() {
|
void findOrCreateByAlias_returnsExactCaseMatch_overCaseInsensitiveSibling() {
|
||||||
String alias = "Walter de Gruyter";
|
String alias = "müller";
|
||||||
Person existing = Person.builder().id(UUID.randomUUID()).alias(alias).build();
|
Person exact = Person.builder().id(UUID.randomUUID()).alias("müller").build();
|
||||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.of(existing));
|
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
|
||||||
|
|
||||||
Person result = personService.findOrCreateByAlias(alias);
|
Person result = personService.findOrCreateByAlias(alias);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(existing);
|
assertThat(result).isEqualTo(exact);
|
||||||
|
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
|
||||||
|
verify(personRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findOrCreateByAlias_returnsExactCaseMatch_evenWhenMultipleSiblingsCollide() {
|
||||||
|
String alias = "Müller";
|
||||||
|
Person exact = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
|
||||||
|
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
|
||||||
|
|
||||||
|
Person result = personService.findOrCreateByAlias(alias);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(exact);
|
||||||
|
// exact-case short-circuits — the case-insensitive siblings are never consulted.
|
||||||
|
verify(personRepository, never()).findAllByAliasIgnoreCase(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findOrCreateByAlias_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
|
||||||
|
String alias = "müller";
|
||||||
|
Person only = Person.builder().id(UUID.randomUUID()).alias("Müller").build();
|
||||||
|
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(only));
|
||||||
|
|
||||||
|
Person result = personService.findOrCreateByAlias(alias);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(only);
|
||||||
|
verify(personRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findOrCreateByAlias_returnsLowestIdDeterministically_whenMultipleCaseInsensitiveMatches() {
|
||||||
|
String alias = "müller";
|
||||||
|
Person lower = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000001")).alias("Müller").build();
|
||||||
|
Person higher = Person.builder().id(UUID.fromString("00000000-0000-0000-0000-000000000002")).alias("müller").build();
|
||||||
|
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of(higher, lower)); // unordered
|
||||||
|
|
||||||
|
Person first = personService.findOrCreateByAlias(alias);
|
||||||
|
Person second = personService.findOrCreateByAlias(alias);
|
||||||
|
|
||||||
|
assertThat(first.getId()).isEqualTo(lower.getId()); // lowest id wins
|
||||||
|
assertThat(second.getId()).isEqualTo(first.getId()); // same result every call — never throws
|
||||||
verify(personRepository, never()).save(any());
|
verify(personRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +433,8 @@ class PersonServiceTest {
|
|||||||
void findOrCreateByAlias_createsNew_whenAliasNotFound() {
|
void findOrCreateByAlias_createsNew_whenAliasNotFound() {
|
||||||
String alias = "Clara Cram";
|
String alias = "Clara Cram";
|
||||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||||
when(personRepository.save(any())).thenReturn(saved);
|
when(personRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
Person result = personService.findOrCreateByAlias(alias);
|
Person result = personService.findOrCreateByAlias(alias);
|
||||||
@@ -352,7 +447,8 @@ class PersonServiceTest {
|
|||||||
void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() {
|
void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() {
|
||||||
String alias = "Clara Cram geb. de Gruyter";
|
String alias = "Clara Cram geb. de Gruyter";
|
||||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||||
when(personRepository.save(any())).thenReturn(saved);
|
when(personRepository.save(any())).thenReturn(saved);
|
||||||
when(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0);
|
when(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0);
|
||||||
when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
@@ -374,7 +470,8 @@ class PersonServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() {
|
void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() {
|
||||||
String alias = "Arthur Collignon GmbH";
|
String alias = "Arthur Collignon GmbH";
|
||||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||||
when(personRepository.save(any())).thenAnswer(inv -> {
|
when(personRepository.save(any())).thenAnswer(inv -> {
|
||||||
Person p = inv.getArgument(0);
|
Person p = inv.getArgument(0);
|
||||||
p.setId(UUID.randomUUID());
|
p.setId(UUID.randomUUID());
|
||||||
@@ -391,7 +488,8 @@ class PersonServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void findOrCreateByAlias_setsGroupType_withFullNameInLastName() {
|
void findOrCreateByAlias_setsGroupType_withFullNameInLastName() {
|
||||||
String alias = "Geschwister de Gruyter";
|
String alias = "Geschwister de Gruyter";
|
||||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||||
when(personRepository.save(any())).thenAnswer(inv -> {
|
when(personRepository.save(any())).thenAnswer(inv -> {
|
||||||
Person p = inv.getArgument(0);
|
Person p = inv.getArgument(0);
|
||||||
p.setId(UUID.randomUUID());
|
p.setId(UUID.randomUUID());
|
||||||
@@ -409,7 +507,8 @@ class PersonServiceTest {
|
|||||||
void findOrCreateByAlias_noAlias_whenNoGeb() {
|
void findOrCreateByAlias_noAlias_whenNoGeb() {
|
||||||
String alias = "Clara Cram";
|
String alias = "Clara Cram";
|
||||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.empty());
|
when(personRepository.findByAlias(alias)).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.findAllByAliasIgnoreCase(alias)).thenReturn(List.of());
|
||||||
when(personRepository.save(any())).thenReturn(saved);
|
when(personRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
personService.findOrCreateByAlias(alias);
|
personService.findOrCreateByAlias(alias);
|
||||||
@@ -421,11 +520,54 @@ class PersonServiceTest {
|
|||||||
void findOrCreateByAlias_trimsInput() {
|
void findOrCreateByAlias_trimsInput() {
|
||||||
String alias = " Clara Cram ";
|
String alias = " Clara Cram ";
|
||||||
Person saved = Person.builder().id(UUID.randomUUID()).alias("Clara Cram").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).alias("Clara Cram").build();
|
||||||
when(personRepository.findByAliasIgnoreCase("Clara Cram")).thenReturn(Optional.of(saved));
|
when(personRepository.findByAlias("Clara Cram")).thenReturn(Optional.of(saved));
|
||||||
|
|
||||||
personService.findOrCreateByAlias(alias);
|
personService.findOrCreateByAlias(alias);
|
||||||
|
|
||||||
verify(personRepository).findByAliasIgnoreCase("Clara Cram");
|
verify(personRepository).findByAlias("Clara Cram");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findByName (filename-based sender resolution) ────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByName_returnsExactCaseMatch_overCaseInsensitiveSibling() {
|
||||||
|
Person exact = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
|
when(personRepository.findByFirstNameAndLastName("Hans", "Müller")).thenReturn(Optional.of(exact));
|
||||||
|
|
||||||
|
assertThat(personService.findByName("Hans", "Müller")).contains(exact);
|
||||||
|
verify(personRepository, never()).findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByName_usesSingleCaseInsensitiveMatch_whenNoExactCase() {
|
||||||
|
Person only = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
|
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
|
||||||
|
.thenReturn(List.of(only));
|
||||||
|
|
||||||
|
assertThat(personService.findByName("hans", "müller")).contains(only);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByName_bailsToEmpty_whenTwoOrMoreCaseInsensitiveMatches() {
|
||||||
|
Person a = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
|
Person b = Person.builder().id(UUID.randomUUID()).firstName("hans").lastName("müller").build();
|
||||||
|
when(personRepository.findByFirstNameAndLastName("hans", "müller")).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("hans", "müller"))
|
||||||
|
.thenReturn(List.of(a, b));
|
||||||
|
|
||||||
|
// Ambiguous sender → unset, never an arbitrary guess (provenance correctness over a
|
||||||
|
// confidently-wrong pre-fill). This is the deliberate divergence from the alias path.
|
||||||
|
assertThat(personService.findByName("hans", "müller")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByName_returnsEmpty_whenFirstNameNullFoldsToNoMatch() {
|
||||||
|
when(personRepository.findByFirstNameAndLastName(null, "Müller")).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
assertThat(personService.findByName(null, "Müller")).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── updatePerson (notes) ────────────────────────────────────────────────
|
// ─── updatePerson (notes) ────────────────────────────────────────────────
|
||||||
@@ -652,10 +794,14 @@ class PersonServiceTest {
|
|||||||
|
|
||||||
personService.mergePersons(sourceId, targetId);
|
personService.mergePersons(sourceId, targetId);
|
||||||
|
|
||||||
|
verify(personRepository).findById(sourceId);
|
||||||
|
verify(personRepository).findById(targetId);
|
||||||
verify(personRepository).reassignSender(sourceId, targetId);
|
verify(personRepository).reassignSender(sourceId, targetId);
|
||||||
verify(personRepository).insertMissingReceiverReference(sourceId, targetId);
|
verify(personRepository).insertMissingReceiverReference(sourceId, targetId);
|
||||||
verify(personRepository).deleteReceiverReferences(sourceId);
|
|
||||||
verify(personRepository).deleteById(sourceId);
|
verify(personRepository).deleteById(sourceId);
|
||||||
|
// The source's leftover receiver rows cascade-drop via V71's ON DELETE CASCADE on
|
||||||
|
// deleteById — merge no longer deletes them explicitly.
|
||||||
|
verifyNoMoreInteractions(personRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── getAliases ─────────────────────────────────────────────────────────
|
// ─── getAliases ─────────────────────────────────────────────────────────
|
||||||
@@ -752,4 +898,15 @@ class PersonServiceTest {
|
|||||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
.isEqualTo(403);
|
.isEqualTo(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByDisplayNameContaining_delegatesToSearchByName() {
|
||||||
|
Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
|
||||||
|
when(personRepository.searchByName("Walter")).thenReturn(List.of(walter));
|
||||||
|
|
||||||
|
List<Person> result = personService.findByDisplayNameContaining("Walter");
|
||||||
|
|
||||||
|
assertThat(result).containsExactly(walter);
|
||||||
|
verify(personRepository).searchByName("Walter");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class RelationshipControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception {
|
void getNetwork_returns200_with_NetworkDTO_for_authenticated_user() throws Exception {
|
||||||
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, true);
|
PersonNodeDTO node = new PersonNodeDTO(PERSON_ID, "Alice Müller", 1900, 1980, null, true);
|
||||||
RelationshipDTO edge = new RelationshipDTO(
|
RelationshipDTO edge = new RelationshipDTO(
|
||||||
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
UUID.randomUUID(), PERSON_ID, OTHER_ID,
|
||||||
"Alice Müller", 1900, 1980,
|
"Alice Müller", 1900, 1980,
|
||||||
@@ -111,7 +111,7 @@ class RelationshipControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception {
|
void getInferredRelationships_returns200_with_list_for_authenticated_user() throws Exception {
|
||||||
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, true);
|
PersonNodeDTO relative = new PersonNodeDTO(OTHER_ID, "Bob Müller", 1930, null, null, true);
|
||||||
InferredRelationshipWithPersonDTO inferred =
|
InferredRelationshipWithPersonDTO inferred =
|
||||||
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
|
new InferredRelationshipWithPersonDTO(relative, "Großvater", 2);
|
||||||
when(relationshipService.getInferredRelationships(PERSON_ID))
|
when(relationshipService.getInferredRelationships(PERSON_ID))
|
||||||
|
|||||||
@@ -144,10 +144,12 @@ class RelationshipServiceIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void setFamilyMember_true_makes_person_appear_in_network() {
|
void setFamilyMember_true_makes_person_appear_in_network() {
|
||||||
// charlie starts with familyMember = false. Add a PARENT_OF edge alice→charlie
|
// addRelationship side-effects family_member=true on both endpoints for family-graph
|
||||||
// so the edge exists, then flip charlie's flag and verify he appears in nodes.
|
// edges (PARENT_OF/SPOUSE_OF/SIBLING_OF). Reset charlie so the explicit
|
||||||
|
// setFamilyMember(true) call below is the thing under test, not the auto-flip.
|
||||||
relationshipService.addRelationship(alice.getId(),
|
relationshipService.addRelationship(alice.getId(),
|
||||||
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
|
new CreateRelationshipRequest(charlie.getId(), RelationType.PARENT_OF, null, null, null));
|
||||||
|
relationshipService.setFamilyMember(charlie.getId(), false);
|
||||||
|
|
||||||
NetworkDTO before = relationshipService.getFamilyNetwork();
|
NetworkDTO before = relationshipService.getFamilyNetwork();
|
||||||
assertThat(before.nodes()).extracting("id").doesNotContain(charlie.getId());
|
assertThat(before.nodes()).extracting("id").doesNotContain(charlie.getId());
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import java.util.UUID;
|
|||||||
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.anyBoolean;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
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;
|
||||||
@@ -148,6 +150,50 @@ class RelationshipServiceTest {
|
|||||||
assertThat(result.notes()).isEqualTo("first born");
|
assertThat(result.notes()).isEqualTo("first born");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_marks_both_endpoints_as_family_member_when_type_is_family() {
|
||||||
|
// Creating a family-graph edge (PARENT_OF / SPOUSE_OF / SIBLING_OF) must mark both
|
||||||
|
// endpoints as family members so they appear in findAllFamilyMembers and the network.
|
||||||
|
// This is what makes the canonical importer's relationships actually show up in the UI.
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.existsByPersonIdAndRelatedPersonIdAndRelationType(
|
||||||
|
bob.getId(), alice.getId(), RelationType.PARENT_OF)).thenReturn(false);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
|
||||||
|
PersonRelationship r = inv.getArgument(0);
|
||||||
|
r.setId(UUID.randomUUID());
|
||||||
|
r.setCreatedAt(Instant.now());
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.PARENT_OF, null, null, null);
|
||||||
|
service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
|
verify(personService).setFamilyMember(alice.getId(), true);
|
||||||
|
verify(personService).setFamilyMember(bob.getId(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addRelationship_does_not_flip_family_member_for_non_family_type() {
|
||||||
|
// FRIEND / COLLEAGUE / EMPLOYER / DOCTOR / NEIGHBOR / OTHER are NOT family-graph
|
||||||
|
// edges (see getFamilyNetwork's filter), so addRelationship must leave family_member
|
||||||
|
// alone — a doctor of the family is not a family member.
|
||||||
|
when(personService.getById(alice.getId())).thenReturn(alice);
|
||||||
|
when(personService.getById(bob.getId())).thenReturn(bob);
|
||||||
|
when(relationshipRepository.saveAndFlush(any())).thenAnswer(inv -> {
|
||||||
|
PersonRelationship r = inv.getArgument(0);
|
||||||
|
r.setId(UUID.randomUUID());
|
||||||
|
r.setCreatedAt(Instant.now());
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
|
||||||
|
var dto = new CreateRelationshipRequest(bob.getId(), RelationType.FRIEND, null, null, null);
|
||||||
|
service.addRelationship(alice.getId(), dto);
|
||||||
|
|
||||||
|
verify(personService, never()).setFamilyMember(eq(alice.getId()), anyBoolean());
|
||||||
|
verify(personService, never()).setFamilyMember(eq(bob.getId()), anyBoolean());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteRelationship_succeeds_when_viewpoint_is_object() {
|
void deleteRelationship_succeeds_when_viewpoint_is_object() {
|
||||||
UUID relId = UUID.randomUUID();
|
UUID relId = UUID.randomUUID();
|
||||||
@@ -191,6 +237,22 @@ class RelationshipServiceTest {
|
|||||||
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
|
assertThat(result.edges().get(0).relatedPersonId()).isEqualTo(bob.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getFamilyNetwork_populates_generation_on_PersonNodeDTO() {
|
||||||
|
Person walter = Person.builder().id(UUID.randomUUID()).lastName("Raddatz")
|
||||||
|
.familyMember(true).generation(2).build();
|
||||||
|
Person clara = Person.builder().id(UUID.randomUUID()).lastName("Raddatz")
|
||||||
|
.familyMember(true).generation(3).build();
|
||||||
|
when(personService.findAllFamilyMembers()).thenReturn(List.of(walter, clara));
|
||||||
|
when(relationshipRepository.findAllByRelationTypeIn(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
NetworkDTO result = service.getFamilyNetwork();
|
||||||
|
|
||||||
|
assertThat(result.nodes()).hasSize(2);
|
||||||
|
assertThat(result.nodes().stream().map(n -> n.generation()).toList())
|
||||||
|
.containsExactlyInAnyOrder(2, 3);
|
||||||
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
private static Person person(String name) {
|
private static Person person(String name) {
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.MockitoAnnotations;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||||
|
import org.raddatz.familienarchiv.document.SearchFilters;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
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.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class NlQueryParserServiceTest {
|
||||||
|
|
||||||
|
@Mock OllamaClient ollamaClient;
|
||||||
|
@Mock PersonService personService;
|
||||||
|
@Mock DocumentService documentService;
|
||||||
|
|
||||||
|
NlQueryParserService service;
|
||||||
|
|
||||||
|
static final Pageable PAGE = PageRequest.of(0, 20);
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
MockitoAnnotations.openMocks(this);
|
||||||
|
service = new NlQueryParserService(ollamaClient, personService, documentService);
|
||||||
|
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||||
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
when(documentService.searchDocumentsByPersonId(any(), any(), any(), any()))
|
||||||
|
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Factory helpers ---
|
||||||
|
|
||||||
|
private OllamaExtraction extraction(List<String> names, String role, LocalDate from, LocalDate to,
|
||||||
|
List<String> keywords) {
|
||||||
|
String raw = names.isEmpty() ? "test query" : String.join(" ", names);
|
||||||
|
return new OllamaExtraction(names, role, from, to, keywords, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Person person(UUID id, String firstName, String lastName) {
|
||||||
|
return Person.builder().id(id).firstName(firstName).lastName(lastName).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final UUID P1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||||
|
private static final UUID P2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||||
|
private static final UUID P3 = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
||||||
|
|
||||||
|
// --- 1. Single resolved name + personRole=sender ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_resolvesSingleName_asSender() {
|
||||||
|
Person walter = person(P1, "Walter", "Raddatz");
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||||
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||||
|
|
||||||
|
NlSearchResponse resp = service.search("Was hat Walter geschrieben?", PAGE);
|
||||||
|
|
||||||
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
||||||
|
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
||||||
|
assertThat(cap.getValue().receiver()).isNull();
|
||||||
|
assertThat(resp.interpretation().resolvedPersons()).hasSize(1);
|
||||||
|
assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1);
|
||||||
|
assertThat(resp.interpretation().ambiguousPersons()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Multi-match name → ambiguous, search NOT executed ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_multiMatchName_populatesAmbiguous_andSkipsSearch() {
|
||||||
|
Person a = person(UUID.randomUUID(), "Walter", "Braun");
|
||||||
|
Person b = person(UUID.randomUUID(), "Walter", "Schmidt");
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||||
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(a, b));
|
||||||
|
|
||||||
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||||
|
|
||||||
|
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||||
|
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
|
||||||
|
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||||
|
assertThat(resp.interpretation().resolvedPersons()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Multi-match + personRole=any → still ambiguous, search NOT executed ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_multiMatchName_withPersonRoleAny_stillSkipsSearch() {
|
||||||
|
Person a = person(UUID.randomUUID(), "Emma", "Braun");
|
||||||
|
Person b = person(UUID.randomUUID(), "Emma", "Raddatz");
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of("Emma"), "any", null, null, List.of()));
|
||||||
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(a, b));
|
||||||
|
|
||||||
|
NlSearchResponse resp = service.search("Briefe an Emma", PAGE);
|
||||||
|
|
||||||
|
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||||
|
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
|
||||||
|
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. No-match name → folded into text ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_noMatchName_isFoldedIntoText() {
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of("Karl"), "any", null, null, List.of()));
|
||||||
|
when(personService.findByDisplayNameContaining("Karl")).thenReturn(List.of());
|
||||||
|
|
||||||
|
service.search("Briefe von Karl", PAGE);
|
||||||
|
|
||||||
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||||
|
assertThat(cap.getValue().text()).contains("Karl");
|
||||||
|
assertThat(cap.getValue().sender()).isNull();
|
||||||
|
assertThat(cap.getValue().receiver()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. personRole=any + 1 resolved → searchDocumentsByPersonId called ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_personRoleAny_singleMatch_callsSearchDocumentsByPersonId() {
|
||||||
|
Person walter = person(P1, "Walter", "Raddatz");
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of()));
|
||||||
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||||
|
|
||||||
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||||
|
|
||||||
|
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
|
||||||
|
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||||
|
assertThat(resp.interpretation().keywordsApplied()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. 2 names both resolve → sender=person1, receiver=person2 ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_twoNamesResolve_assignsSenderAndReceiver() {
|
||||||
|
Person walter = person(P1, "Walter", "Raddatz");
|
||||||
|
Person emma = person(P2, "Emma", "Raddatz");
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of()));
|
||||||
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||||
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
||||||
|
|
||||||
|
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
||||||
|
|
||||||
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
||||||
|
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
||||||
|
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
||||||
|
assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1);
|
||||||
|
assertThat(resp.interpretation().resolvedPersons().get(1).id()).isEqualTo(P2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 7. 2 names, first resolves, second ambiguous → search NOT executed ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_twoNames_secondAmbiguous_skipsSearch() {
|
||||||
|
Person walter = person(P1, "Walter", "Raddatz");
|
||||||
|
Person emma1 = person(P2, "Emma", "Braun");
|
||||||
|
Person emma2 = person(P3, "Emma", "Schmidt");
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of()));
|
||||||
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||||
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma1, emma2));
|
||||||
|
|
||||||
|
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
||||||
|
|
||||||
|
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||||
|
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 8. 2 names, first no match → folded into text, second used as single person ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_twoNames_firstNoMatch_secondResolved_foldFirstIntoText() {
|
||||||
|
Person emma = person(P2, "Emma", "Raddatz");
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of()));
|
||||||
|
when(personService.findByDisplayNameContaining("Karl")).thenReturn(List.of());
|
||||||
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
||||||
|
|
||||||
|
service.search("Briefe von Karl an Emma", PAGE);
|
||||||
|
|
||||||
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||||
|
assertThat(cap.getValue().text()).contains("Karl");
|
||||||
|
assertThat(cap.getValue().sender()).isEqualTo(P2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 9. 3+ names all resolve → first two as sender/receiver, third folded into text ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_threeNamesResolve_extraFoldedIntoText() {
|
||||||
|
Person walter = person(P1, "Walter", "Raddatz");
|
||||||
|
Person emma = person(P2, "Emma", "Raddatz");
|
||||||
|
Person heinrich = person(P3, "Heinrich", "Braun");
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of()));
|
||||||
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||||
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
||||||
|
when(personService.findByDisplayNameContaining("Heinrich")).thenReturn(List.of(heinrich));
|
||||||
|
|
||||||
|
service.search("Briefe von Walter an Emma über Heinrich", PAGE);
|
||||||
|
|
||||||
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||||
|
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
||||||
|
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
||||||
|
assertThat(cap.getValue().text()).contains("Heinrich");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 10. Keywords space-joined into text ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_keywords_areJoinedIntoText() {
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg", "Walter")));
|
||||||
|
|
||||||
|
service.search("Dokumente über den Krieg Walter", PAGE);
|
||||||
|
|
||||||
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||||
|
assertThat(cap.getValue().text()).isEqualTo("Krieg Walter");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 11. Date range passed through ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_dateRange_passedIntoSearchFilters() {
|
||||||
|
LocalDate from = LocalDate.of(1914, 1, 1);
|
||||||
|
LocalDate to = LocalDate.of(1914, 12, 31);
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of(), "any", from, to, List.of()));
|
||||||
|
|
||||||
|
service.search("Briefe aus dem Jahr 1914", PAGE);
|
||||||
|
|
||||||
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||||
|
assertThat(cap.getValue().from()).isEqualTo(from);
|
||||||
|
assertThat(cap.getValue().to()).isEqualTo(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 12. Null dates → null in SearchFilters (not an error) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_nullDates_passedAsNullIntoFilters() {
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||||
|
|
||||||
|
service.search("Hochzeitsbriefe", PAGE);
|
||||||
|
|
||||||
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||||
|
assertThat(cap.getValue().from()).isNull();
|
||||||
|
assertThat(cap.getValue().to()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 13. Query under 3 chars → VALIDATION_ERROR before Ollama call ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_queryTooShort_throwsValidationError() {
|
||||||
|
assertThatThrownBy(() -> service.search("ab", PAGE))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
|
|
||||||
|
verify(ollamaClient, never()).parse(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 14. Query over 500 chars → VALIDATION_ERROR ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_queryTooLong_throwsValidationError() {
|
||||||
|
String longQuery = "a".repeat(501);
|
||||||
|
assertThatThrownBy(() -> service.search(longQuery, PAGE))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||||
|
|
||||||
|
verify(ollamaClient, never()).parse(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 15. Ollama returns empty names/keywords → raw query used as keyword fallback ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_ollamaReturnsEmpty_usesRawQueryAsTextFallback() {
|
||||||
|
String raw = "Briefe aus dem Krieg";
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of(), raw));
|
||||||
|
|
||||||
|
service.search(raw, PAGE);
|
||||||
|
|
||||||
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||||
|
assertThat(cap.getValue().text()).isEqualTo(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 16. Null personNames/keywords from Ollama → no NPE ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_nullPersonNamesAndKeywords_handledWithoutNpe() {
|
||||||
|
OllamaExtraction ext = new OllamaExtraction(null, "any", null, null, null, "test query");
|
||||||
|
when(ollamaClient.parse(anyString())).thenReturn(ext);
|
||||||
|
|
||||||
|
NlSearchResponse resp = service.search("test query", PAGE);
|
||||||
|
|
||||||
|
assertThat(resp).isNotNull();
|
||||||
|
verify(documentService).searchDocuments(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 17. Unrecognized personRole → defaults to any-like behavior (no crash) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_unrecognizedPersonRole_treatedLikeAny_withSingleResolvedPerson() {
|
||||||
|
Person walter = person(P1, "Walter", "Raddatz");
|
||||||
|
// OllamaClient defensive parsing returns "any" for unknown roles,
|
||||||
|
// but NlQueryParserService must also be safe if something unexpected arrives.
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(new OllamaExtraction(List.of("Walter"), "unknown_role", null, null, List.of(), "query"));
|
||||||
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||||
|
|
||||||
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||||
|
|
||||||
|
// Should not crash; "unknown_role" treated as fallback (neither sender nor receiver → any)
|
||||||
|
assertThat(resp).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 18. Ollama throws SMART_SEARCH_UNAVAILABLE → propagates to caller ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_ollamaThrowsUnavailable_propagates() {
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenThrow(DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, "offline"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.search("Was hat Walter geschrieben?", PAGE))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 19. LLM-extracted name > 200 chars → skipped, PersonService never called ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_nameLongerThan200Chars_isSkippedBeforePersonServiceCall() {
|
||||||
|
String longName = "A".repeat(201);
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of(longName), "sender", null, null, List.of()));
|
||||||
|
|
||||||
|
service.search("Briefe von sehr langem Namen", PAGE);
|
||||||
|
|
||||||
|
verify(personService, never()).findByDisplayNameContaining(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 20. Max 10 candidates cap: 11 persons returned → only first 10 in ambiguousPersons ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_elevenCandidates_capsAtTen() {
|
||||||
|
List<Person> eleven = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 11; i++) {
|
||||||
|
eleven.add(person(UUID.randomUUID(), "Walter", "Person" + i));
|
||||||
|
}
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||||
|
when(personService.findByDisplayNameContaining("Walter")).thenReturn(eleven);
|
||||||
|
|
||||||
|
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||||
|
|
||||||
|
assertThat(resp.interpretation().ambiguousPersons()).hasSize(10);
|
||||||
|
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 21. SearchFilters defaults: tagOperator=AND, status=null, undated=false, tags=empty ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_searchFiltersDefaults_areCorrect() {
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg")));
|
||||||
|
|
||||||
|
service.search("Dokumente über den Krieg", PAGE);
|
||||||
|
|
||||||
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
||||||
|
SearchFilters f = cap.getValue();
|
||||||
|
assertThat(f.tagOperator()).isEqualTo(TagOperator.AND);
|
||||||
|
assertThat(f.status()).isNull();
|
||||||
|
assertThat(f.undated()).isFalse();
|
||||||
|
assertThat(f.tags()).isEmpty();
|
||||||
|
assertThat(f.tagQ()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 22. personRole=receiver + 1 resolved → receiver UUID set ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_personRoleReceiver_singleMatch_setsReceiver() {
|
||||||
|
Person emma = person(P2, "Emma", "Raddatz");
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of("Emma"), "receiver", null, null, List.of()));
|
||||||
|
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
||||||
|
|
||||||
|
service.search("Briefe an Emma", PAGE);
|
||||||
|
|
||||||
|
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||||
|
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||||
|
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
||||||
|
assertThat(cap.getValue().sender()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 23. keywordsApplied=true when text is non-blank ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_keywordsApplied_trueWhenTextNonBlank() {
|
||||||
|
when(ollamaClient.parse(anyString()))
|
||||||
|
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
|
||||||
|
|
||||||
|
NlSearchResponse resp = service.search("Feldpost aus dem Krieg", PAGE);
|
||||||
|
|
||||||
|
assertThat(resp.interpretation().keywordsApplied()).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package org.raddatz.familienarchiv.search;
|
||||||
|
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
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.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@WebMvcTest(NlSearchController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class,
|
||||||
|
NlSearchRateLimiter.class, NlSearchRateLimitProperties.class})
|
||||||
|
class NlSearchControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@MockitoBean NlQueryParserService nlQueryParserService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
@Autowired NlSearchRateLimiter rateLimiter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void resetRateLimiter() {
|
||||||
|
rateLimiter.resetForTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
private NlSearchResponse makeResponse() {
|
||||||
|
PersonHint hint = new PersonHint(UUID.randomUUID(), "Walter Raddatz");
|
||||||
|
NlQueryInterpretation interp = new NlQueryInterpretation(
|
||||||
|
List.of(hint), List.of(), null, null,
|
||||||
|
List.of("Krieg"), "Briefe von Walter im Krieg", true);
|
||||||
|
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. Happy path ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||||
|
void search_returns200_withNlSearchResponse() throws Exception {
|
||||||
|
when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse());
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"query\":\"Briefe von Walter im Krieg\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.interpretation.rawQuery").value("Briefe von Walter im Krieg"))
|
||||||
|
.andExpect(jsonPath("$.interpretation.resolvedPersons[0].displayName").value("Walter Raddatz"))
|
||||||
|
.andExpect(jsonPath("$.interpretation.keywordsApplied").value(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. ambiguousPersons in response shape ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||||
|
void search_returns200_withAmbiguousPersons() throws Exception {
|
||||||
|
PersonHint a = new PersonHint(UUID.randomUUID(), "Walter Braun");
|
||||||
|
PersonHint b = new PersonHint(UUID.randomUUID(), "Walter Schmidt");
|
||||||
|
NlQueryInterpretation interp = new NlQueryInterpretation(
|
||||||
|
List.of(), List.of(a, b), null, null,
|
||||||
|
List.of(), "Briefe von Walter", false);
|
||||||
|
NlSearchResponse resp = new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
|
||||||
|
when(nlQueryParserService.search(anyString(), any())).thenReturn(resp);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"query\":\"Briefe von Walter\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.interpretation.ambiguousPersons").isArray())
|
||||||
|
.andExpect(jsonPath("$.interpretation.ambiguousPersons[0].displayName").value("Walter Braun"))
|
||||||
|
.andExpect(jsonPath("$.interpretation.ambiguousPersons[1].id").isNotEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Unauthenticated → 401 ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"query\":\"Briefe von Walter\"}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. Query < 3 chars → 400 ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||||
|
void search_returns400_whenQueryTooShort() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"query\":\"ab\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 5. Query > 500 chars → 400 ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||||
|
void search_returns400_whenQueryTooLong() throws Exception {
|
||||||
|
String longQuery = "a".repeat(501);
|
||||||
|
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"query\":\"" + longQuery + "\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 6. Ollama unavailable → 503 ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||||
|
void search_returns503_whenOllamaUnavailable() throws Exception {
|
||||||
|
when(nlQueryParserService.search(anyString(), any()))
|
||||||
|
.thenThrow(DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, "Ollama offline"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"query\":\"Briefe von Walter\"}"))
|
||||||
|
.andExpect(status().isServiceUnavailable())
|
||||||
|
.andExpect(jsonPath("$.code").value("SMART_SEARCH_UNAVAILABLE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 7. 6th request in 1 minute → 429 ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||||
|
void search_returns429_onSixthRequestWithinRateLimit() throws Exception {
|
||||||
|
when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse());
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"query\":\"Briefe von Walter\"}"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"query\":\"Briefe von Walter\"}"))
|
||||||
|
.andExpect(status().isTooManyRequests())
|
||||||
|
.andExpect(jsonPath("$.code").value("SMART_SEARCH_RATE_LIMITED"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user