Compare commits
483 Commits
397fc3c7e4
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f65296172f | ||
|
|
6230deaa96 | ||
|
|
00baf0d881 | ||
|
|
84c5ff5b3f | ||
|
|
e9b8920b3b | ||
|
|
c2b98e6e84 | ||
|
|
eefc67bd81 | ||
|
|
44869d64f7 | ||
|
|
7ca6492fc0 | ||
|
|
9ea21f60ea | ||
|
|
a6184fa121 | ||
|
|
232721214d | ||
|
|
d91bedbaaf | ||
|
|
8e4810d5da | ||
|
|
05652a18ee | ||
|
|
6b2dd2f259 | ||
|
|
f43df6082d | ||
|
|
2121d8469f | ||
|
|
e8437b79d1 | ||
|
|
4f0a660cb8 | ||
|
|
48f2c67ffc | ||
|
|
4961c74a01 | ||
|
|
d0fc8ce995 | ||
|
|
73b10e6f8c | ||
|
|
ac6da154da | ||
|
|
66e9309d8a | ||
|
|
836c9594d4 | ||
|
|
07725cfa39 | ||
|
|
b4fcbd7efc | ||
|
|
8995b6e922 | ||
|
|
b9bed19610 | ||
|
|
98805f596a | ||
|
|
2e9902e8a7 | ||
|
|
0884a1abcd | ||
|
|
50def73d80 | ||
|
|
264d7268c4 | ||
|
|
2d38122833 | ||
|
|
9c524443cf | ||
|
|
3b58ac0457 | ||
|
|
63acb5417f | ||
|
|
b926bdefde | ||
|
|
cbf4432d01 | ||
|
|
f2ebaacfb1 | ||
|
|
07ed9719e7 | ||
|
|
906e75ec96 | ||
|
|
90a1bd4082 | ||
|
|
b0d75b26cd | ||
|
|
166003b33a | ||
|
|
7ad2132a92 | ||
|
|
c3afd57e19 | ||
|
|
74b94ccd84 | ||
|
|
585244d65b | ||
|
|
a65a55448e | ||
|
|
4374f75d3c | ||
|
|
f10b0cb73e | ||
|
|
7977d22d0b | ||
|
|
98e3d924e5 | ||
|
|
c4606cef8b | ||
|
|
d6ac88a211 | ||
|
|
d34afb2298 | ||
|
|
e077dba595 | ||
|
|
b3d54a12c4 | ||
|
|
02efe738a0 | ||
|
|
ae0b143685 | ||
|
|
4e28f2f31b | ||
|
|
282c819e2a | ||
|
|
6e006bafd0 | ||
|
|
e988c3eae7 | ||
|
|
522d1f3ec9 | ||
|
|
44f15dd4a2 | ||
|
|
bd5e6e6fbe | ||
|
|
3e54b6e90a | ||
|
|
abe5d62102 | ||
|
|
ba394e1165 | ||
|
|
0130c4fa99 | ||
|
|
d689a66275 | ||
|
|
401371fd18 | ||
|
|
33fe93a591 | ||
|
|
d35a165881 | ||
|
|
7df640201a | ||
|
|
8adc39e2ce | ||
|
|
67861239f8 | ||
|
|
b30c0d7f96 | ||
|
|
e1ca2c6831 | ||
|
|
55989058a5 | ||
|
|
1cfa03d1f0 | ||
|
|
63bc24d2f1 | ||
|
|
e5f276a164 | ||
|
|
949421a076 | ||
|
|
353945c952 | ||
|
|
4572572c94 | ||
|
|
dfc065b727 | ||
|
|
ed4ef88598 | ||
|
|
65e196292e | ||
|
|
afe7e5586a | ||
|
|
53f259243e | ||
|
|
db4a5c0028 | ||
|
|
1b2776aa89 | ||
|
|
8df5b77189 | ||
|
|
06b8c99ce7 | ||
|
|
39e07fc02e | ||
|
|
7e6030a4fc | ||
|
|
dd917460b0 | ||
|
|
573e5c43d7 | ||
|
|
a5754162ce | ||
|
|
ddcf61cc5e | ||
|
|
1f9107b620 | ||
|
|
ae0cb93a9e | ||
|
|
a17eec537f | ||
|
|
9a178210fa | ||
|
|
d88cde06a0 | ||
|
|
65d241f69e | ||
|
|
a619f950a5 | ||
|
|
65b79a337b | ||
|
|
4de664f4f6 | ||
|
|
bee055e615 | ||
|
|
9be24f2613 | ||
|
|
d5441d3e55 | ||
|
|
c131507e30 | ||
|
|
c50f04bafa | ||
|
|
f004b1f2a6 | ||
|
|
75de56928e | ||
|
|
6ed8ecf571 | ||
|
|
4c75680977 | ||
|
|
930f69e884 | ||
|
|
eea8e6bf5a | ||
|
|
55e3e4c531 | ||
|
|
7a5c2d0ba3 | ||
|
|
994772564a | ||
|
|
a0930b62b0 | ||
|
|
3572de487a | ||
|
|
f9cdc02a77 | ||
|
|
4c24bbb002 | ||
|
|
91d9dae6fd | ||
|
|
4184d0775b | ||
|
|
97026fec11 | ||
|
|
565eddd743 | ||
|
|
0b9e8c2abb | ||
|
|
8a6bc27979 | ||
|
|
8fea94cb61 | ||
|
|
0d47bcb4a1 | ||
|
|
825a622413 | ||
|
|
81a12ba35c | ||
| 0780c09bb4 | |||
|
|
77cbbd34a0 | ||
|
|
84b47f1836 | ||
|
|
99111273e5 | ||
|
|
f09c79744e | ||
|
|
1108277472 | ||
|
|
9db3b41fdb | ||
|
|
73004ce49f | ||
|
|
c31f82a69c | ||
|
|
f9ae6a91ba | ||
|
|
70da532f54 | ||
|
|
ad90ae75bf | ||
|
|
164178ecf1 | ||
|
|
3f36d2a7f1 | ||
|
|
5b2ee31292 | ||
|
|
3d80bc656c | ||
|
|
4a0fed617a | ||
|
|
7ba6342a84 | ||
|
|
598ad622e7 | ||
|
|
c5611250ec | ||
|
|
e400b1d77e | ||
|
|
1fb0c41216 | ||
|
|
147aa56386 | ||
|
|
7c06609816 | ||
|
|
2ae1bb3a30 | ||
|
|
69db198319 | ||
|
|
e157d90b53 | ||
|
|
97f22e1ce8 | ||
|
|
5539158e8f | ||
|
|
7ed0032661 | ||
|
|
2f471155b8 | ||
|
|
4eb6abd920 | ||
|
|
6fc5ce6ddd | ||
|
|
4603e335fd | ||
|
|
a0fa8f4d02 | ||
|
|
d29f217328 | ||
|
|
fdc9273c86 | ||
|
|
2ad5c36e3c | ||
|
|
160ca1c3e9 | ||
|
|
7b06c3adec | ||
|
|
408ae3345c | ||
|
|
0b17724785 | ||
|
|
df5d880e09 | ||
|
|
45500cc5e2 | ||
|
|
b1819867be | ||
|
|
2c5f7ac12d | ||
|
|
13fa4123c1 | ||
|
|
ccdf358b40 | ||
|
|
e6c890c61e | ||
|
|
93ef26690f | ||
|
|
439385dd35 | ||
|
|
b3ce9b930f | ||
| d650b6c066 | |||
|
|
8e63867ad8 | ||
|
|
6b0a06e8b1 | ||
|
|
7c1eef710c | ||
|
|
03e22a2f26 | ||
|
|
6878419156 | ||
|
|
09b77e9b36 | ||
|
|
9d202b042b | ||
|
|
8429b1e9f8 | ||
|
|
6959651b36 | ||
|
|
0ef4f4f07c | ||
|
|
f1bb9d3a69 | ||
|
|
ca52145556 | ||
|
|
9a26bf75b0 | ||
|
|
9c616f9fb8 | ||
|
|
0fe0ae5235 | ||
|
|
2c909f49a8 | ||
|
|
87fd0f39bb | ||
|
|
7f3ad8ce89 | ||
|
|
aa1f6436cc | ||
|
|
b825076733 | ||
|
|
01df815bad | ||
|
|
dcd0e725a7 | ||
|
|
39ff63921d | ||
|
|
5a09cd4cb4 | ||
|
|
4e0ebc72c8 | ||
|
|
0f0d89702d | ||
|
|
fb41affd4c | ||
|
|
dc366ed403 | ||
|
|
64b7b2315d | ||
|
|
2a7e133717 | ||
|
|
5387bc9247 | ||
|
|
847874abb3 | ||
|
|
573bca4986 | ||
|
|
86690fdbb6 | ||
|
|
6cb1025881 | ||
|
|
fc557bd9ae | ||
|
|
e94414b81a | ||
|
|
7eee688ce9 | ||
|
|
8905135006 | ||
|
|
8bd8390891 | ||
|
|
ed98729f75 | ||
|
|
db87a64cc0 | ||
|
|
d7d6d0638c | ||
|
|
a2f37f85a6 | ||
|
|
f22a1a1cfa | ||
|
|
2a0863cf3e | ||
|
|
9e97687d0f | ||
|
|
b665e1132d | ||
|
|
87af9ab446 | ||
|
|
0058b297d8 | ||
|
|
230f23e37c | ||
|
|
e604967a3f | ||
|
|
169e1ad9de | ||
|
|
f2f42ed415 | ||
|
|
5945824b54 | ||
|
|
fa41394e66 | ||
|
|
fb00c7818e | ||
|
|
8ed65f8602 | ||
|
|
9e425c98a1 | ||
|
|
ddce268113 | ||
| 4a43962c98 | |||
|
|
9a9e1c4c40 | ||
|
|
62c8ce4cb2 | ||
|
|
4c620619d4 | ||
|
|
44baff9c9c | ||
|
|
4634da9865 | ||
|
|
79e4a3f9db | ||
|
|
70e8a6e6ad | ||
|
|
3af1095d13 | ||
|
|
8c835e957a | ||
|
|
fe8fcba7a7 | ||
|
|
e0c80ac193 | ||
|
|
005265b5a8 | ||
|
|
684c6e63de | ||
|
|
e27d52b9ee | ||
|
|
6f5497c7bf | ||
|
|
e0fac783e8 | ||
|
|
202ea85a58 | ||
|
|
7679596c70 | ||
|
|
3d5dcd1f18 | ||
|
|
52fca38f0f | ||
|
|
662a8f3e80 | ||
|
|
cbba95c3f8 | ||
|
|
3536ed884c | ||
|
|
5a939d9222 | ||
|
|
93e90424ab | ||
|
|
e8f3004c4f | ||
|
|
9637ebbca2 | ||
|
|
df10a42069 | ||
|
|
64120a30b5 | ||
|
|
25252fc709 | ||
|
|
1f379a161d | ||
|
|
c0d034c85d | ||
|
|
ca93cde06e | ||
|
|
7629e35897 | ||
|
|
cd741b9f57 | ||
|
|
ddf378aaac | ||
|
|
20cfe41f21 | ||
|
|
43601a3770 | ||
|
|
6603bc5333 | ||
|
|
6753d115f9 | ||
|
|
73dd6c80fa | ||
|
|
9ade36dd3b | ||
|
|
378da60ae8 | ||
|
|
6d267f2269 | ||
|
|
ff76a3784f | ||
|
|
534665459f | ||
|
|
fd792f6d78 | ||
|
|
bafbf609eb | ||
|
|
2710f2e233 | ||
|
|
80f6468d52 | ||
|
|
a58378e8f0 | ||
|
|
d000170f52 | ||
|
|
d1ed9c022f | ||
|
|
1e5e8e43e8 | ||
|
|
8c198f22be | ||
|
|
6fd05e08d8 | ||
|
|
ab469b744c | ||
|
|
f07527158c | ||
|
|
9f75de0350 | ||
|
|
8a9fbc6aef | ||
|
|
0336d07980 | ||
|
|
61256942e1 | ||
|
|
6aaf8ddb9e | ||
|
|
1b9707c6cd | ||
|
|
8353e71eed | ||
|
|
0693cfddd1 | ||
|
|
f656f7c1ff | ||
|
|
7316c51d4a | ||
|
|
cf457cb96f | ||
|
|
83e0afb466 | ||
|
|
12db7b3596 | ||
|
|
26b45f1c78 | ||
|
|
e6ce00035e | ||
|
|
b1f77bcfb6 | ||
|
|
4d1a5862d0 | ||
|
|
4e8a430dc3 | ||
|
|
e1d404609e | ||
|
|
b36addde22 | ||
|
|
456e019c3d | ||
|
|
d3bb08e7ff | ||
|
|
6703347468 | ||
|
|
1d55901388 | ||
|
|
0cd4882ef4 | ||
|
|
a85b22efcf | ||
|
|
7627589844 | ||
|
|
96a1afe09a | ||
|
|
c1b125bdb2 | ||
|
|
e4a9999f2f | ||
|
|
e48c794c12 | ||
|
|
add619d81d | ||
|
|
a46c3b416b | ||
|
|
7e8b90c8ee | ||
|
|
fc5c837d2c | ||
|
|
4f874bf4e9 | ||
|
|
28997fc391 | ||
|
|
003bc9b8cb | ||
|
|
485e13cfea | ||
|
|
439a386a37 | ||
|
|
23006a6562 | ||
|
|
c35f51d209 | ||
|
|
5297c70453 | ||
|
|
ad820955fd | ||
|
|
27b6d58632 | ||
|
|
4db2e97490 | ||
|
|
25b23843c9 | ||
|
|
ad067d2e0e | ||
|
|
29015ee864 | ||
|
|
b1b8505b93 | ||
|
|
abe860bec7 | ||
|
|
ec9d46da7a | ||
|
|
e562b3bbea | ||
|
|
e725910402 | ||
|
|
782a34e34b | ||
|
|
30f450b0d1 | ||
|
|
d4c0287e92 | ||
|
|
301cfc5c9e | ||
|
|
724c3881e4 | ||
|
|
fab2930ca8 | ||
|
|
d83707ec3b | ||
|
|
caea0d5633 | ||
|
|
2bf14aeab9 | ||
|
|
5b565d5271 | ||
|
|
df0f4879b8 | ||
|
|
98d081397e | ||
|
|
4e68b81bf7 | ||
|
|
985b31f71f | ||
|
|
3fb312b1c6 | ||
|
|
e2ec45f819 | ||
|
|
7d9526440a | ||
|
|
13bbfa7abd | ||
|
|
975223c972 | ||
|
|
403a043d51 | ||
|
|
e259908d6a | ||
|
|
7d37e610da | ||
|
|
9c1eb7608b | ||
|
|
9bba5e4a7a | ||
|
|
751a48b22c | ||
|
|
58a30a6e2e | ||
|
|
2430092e43 | ||
|
|
4a93543645 | ||
|
|
b453c13bae | ||
|
|
599c3977fb | ||
|
|
03e2615fa7 | ||
|
|
3db6a3bf8f | ||
|
|
0e06626eef | ||
|
|
a47564934d | ||
|
|
02fb16a0bd | ||
|
|
4757a174c9 | ||
|
|
75293c6aa8 | ||
|
|
4e9b13c0e4 | ||
|
|
ad27c1f757 | ||
|
|
0e30e5c570 | ||
|
|
a6a8552a48 | ||
|
|
b0d28c1e0b | ||
|
|
420c0e3e10 | ||
|
|
cb61e63b02 | ||
|
|
8eb321ccea | ||
|
|
e16b7402bd | ||
|
|
229c1b0539 | ||
|
|
f24c415b04 | ||
|
|
4c57a2262f | ||
|
|
b8e01f997d | ||
|
|
e8e57d2712 | ||
|
|
817835fd6a | ||
|
|
c361b3cd45 | ||
|
|
5c8034d298 | ||
|
|
8b1b070254 | ||
|
|
4ca1c967d2 | ||
|
|
24d9d975d1 | ||
|
|
8a1cc2d1f0 | ||
|
|
d5bf401085 | ||
|
|
4944918692 | ||
|
|
bf90427bfa | ||
|
|
50f554680c | ||
|
|
1dd162f1be | ||
|
|
ff7cfd4b1a | ||
|
|
88600d54cd | ||
|
|
654ac1478c | ||
|
|
3a4c2c6225 | ||
|
|
73f614bc3a | ||
|
|
6c5e5273bb | ||
|
|
a574d96351 | ||
|
|
246568301a | ||
|
|
aab4fe37ae | ||
|
|
4ebebe1e07 | ||
|
|
81224829a2 | ||
|
|
7cc2ddc6ad | ||
|
|
da3067150d | ||
|
|
10249c33be | ||
|
|
9c12f62345 | ||
|
|
e5784caa9d | ||
|
|
4583ee2c4d | ||
|
|
0a7b4fa265 | ||
|
|
a3858b6c80 | ||
|
|
9f5d7b8570 | ||
|
|
f6da95014e | ||
|
|
7a655ce6f4 | ||
|
|
3b594c0b0b | ||
|
|
2e44cab614 | ||
|
|
4c2f036de0 | ||
|
|
dcb57ffacd | ||
|
|
1c961619f1 | ||
|
|
2cc43c3c44 | ||
|
|
6c4d10d12f | ||
|
|
2cdb48f4a4 | ||
|
|
6be7413ba4 | ||
|
|
33aeefbb5b | ||
|
|
4bbdd33344 | ||
|
|
f4f853be8b | ||
|
|
44b5934fa7 | ||
|
|
78cc537f0e | ||
|
|
fc69758a92 | ||
|
|
f55efda0d2 | ||
|
|
77eddfc599 | ||
|
|
a76999c3d4 | ||
|
|
6d4aa8bd5c | ||
|
|
1fc74f8892 | ||
|
|
29ea27319a | ||
|
|
16f1fe7616 | ||
|
|
5ea47d4ec7 | ||
|
|
2f1538754e | ||
|
|
138bf446e4 | ||
|
|
944370dcfd | ||
|
|
5edefdd082 | ||
|
|
97274beba0 | ||
|
|
c3652f5b57 |
@@ -154,9 +154,9 @@ Schedule monthly automated restore tests. If the restore fails, the backup is wo
|
||||
```
|
||||
Every alert needs: description, severity, likely cause, resolution steps, escalation path.
|
||||
|
||||
3. **Upgrading 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
|
||||
```
|
||||
Profile with Grafana dashboards first. Most perceived performance issues are application bugs, not resource constraints.
|
||||
@@ -404,8 +404,8 @@ Hetzner Object Storage (S3-compatible, replaces MinIO in prod)
|
||||
Prometheus + Loki + Alertmanager
|
||||
```
|
||||
|
||||
### Monthly Cost: ~23 EUR
|
||||
CX32 VPS (4 vCPU, 8GB RAM): 17 EUR · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
|
||||
### Monthly Cost: ~6 EUR (excl. server)
|
||||
Hetzner dedicated server (Serverbörse, i7-6700, 64 GB RAM): see invoice · Object Storage (~200GB): 5 EUR · SMTP relay: ~1 EUR
|
||||
|
||||
### Reference Documentation
|
||||
- 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_AUTH_TOKEN=
|
||||
|
||||
# NL search — Ollama LLM inference
|
||||
# Leave APP_OLLAMA_BASE_URL empty to disable NL search (safe default for CX32 / CI).
|
||||
# Set to http://ollama:11434 to enable. Requires CX42 (16 GB RAM) to run alongside OCR.
|
||||
APP_OLLAMA_BASE_URL=http://ollama:11434
|
||||
|
||||
# CPU limit: 4.0 is safe on both CX32 (4 vCPUs) and CX42 (8 vCPUs).
|
||||
# Raise to 7.5 on CX42 for full throughput.
|
||||
OLLAMA_CPU_LIMIT=4.0
|
||||
|
||||
# Memory limit: requires CX42 (16 GB) to run alongside OCR.
|
||||
# Reduce or set APP_OLLAMA_BASE_URL= on smaller hosts.
|
||||
OLLAMA_MEM_LIMIT=8g
|
||||
|
||||
# Ollama API key — set on the Ollama service to restrict inference API access on archiv-net.
|
||||
# Generate with: openssl rand -hex 32
|
||||
# NOTE: Empirically verified that OLLAMA_API_KEY is NOT enforced in Ollama 0.6.5 or 0.30.6 (ADR-028 §7).
|
||||
# archiv-net network isolation is the only effective access control. Retained for forward compatibility.
|
||||
OLLAMA_API_KEY=
|
||||
|
||||
# Production SMTP — uncomment and fill in to send real emails instead of catching them
|
||||
# APP_BASE_URL=https://your-domain.example.com
|
||||
# MAIL_HOST=smtp.example.com
|
||||
|
||||
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
|
||||
fi
|
||||
|
||||
- name: Assert deploy-obs writes obs-secrets.env via an unquoted heredoc (#603)
|
||||
shell: bash
|
||||
run: |
|
||||
# Inside a composite action, secrets arrive as $VAR from env: (secrets.*
|
||||
# is unavailable there), so the obs-secrets.env heredoc MUST use an
|
||||
# unquoted delimiter (<<EOF) for $VAR to expand. A quoted delimiter
|
||||
# (<<'EOF') would write the literal string "$GRAFANA_ADMIN_PASSWORD",
|
||||
# and the action's five-key non-empty guard would STILL pass (the line
|
||||
# is present, just wrong). This guard enforces the invariant in CI so a
|
||||
# future re-quote cannot ship broken obs auth green. See ADR-029 / #603.
|
||||
action='.gitea/actions/deploy-obs/action.yml'
|
||||
quoted='obs-secrets\.env\s*<<-?\s*[\x27\x22]'
|
||||
# Self-test: the regex must catch a quoted delimiter and ignore the unquoted one.
|
||||
printf "obs-secrets.env <<'EOF'\n" | grep -qP "$quoted" \
|
||||
|| { echo "FAIL: guard self-test — regex missed the quoted <<'EOF' form"; exit 1; }
|
||||
printf 'obs-secrets.env <<EOF\n' | grep -qvP "$quoted" \
|
||||
|| { echo "FAIL: guard self-test — regex wrongly flagged the unquoted <<EOF form"; exit 1; }
|
||||
# Positive: the unquoted heredoc must be present at all.
|
||||
grep -qP 'obs-secrets\.env\s*<<-?EOF\b' "$action" \
|
||||
|| { echo "::error::$action no longer writes obs-secrets.env via an unquoted <<EOF heredoc (ADR-029 / #603)"; exit 1; }
|
||||
# Negative: never a quoted delimiter on the obs-secrets.env heredoc.
|
||||
if grep -nP "$quoted" "$action"; then
|
||||
echo "::error::$action writes obs-secrets.env with a quoted heredoc delimiter — secrets would be written as literal \$VAR strings. Use unquoted <<EOF (ADR-029 / #603)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run unit and component tests with coverage
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -23,6 +23,11 @@ name: nightly
|
||||
# - host ports: backend 8081, frontend 3001
|
||||
# - profile: staging (starts mailpit instead of a real SMTP relay)
|
||||
#
|
||||
# The obs-stack deploy, Caddy reload, and smoke test are shared with
|
||||
# release.yml via the composite actions under .gitea/actions/ (ADR-029).
|
||||
# actions/checkout MUST stay the first step: a local `uses: ./…` action
|
||||
# only exists on disk after checkout.
|
||||
#
|
||||
# Required Gitea secrets:
|
||||
# STAGING_POSTGRES_PASSWORD
|
||||
# STAGING_MINIO_PASSWORD
|
||||
@@ -55,6 +60,8 @@ jobs:
|
||||
# for the same repo is within that boundary.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# MUST be first: the composite actions below live under .gitea/actions/
|
||||
# and only exist on disk once the repo is checked out (ADR-029).
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Write staging env file
|
||||
@@ -92,6 +99,7 @@ jobs:
|
||||
# `compose config` renders both shorthand and longform mounts as
|
||||
# `target: /import` + `read_only: true`, so we assert against
|
||||
# the rendered form rather than the raw source YAML.
|
||||
# App-compose check (not obs), nightly-only — stays inline.
|
||||
run: |
|
||||
set -e
|
||||
docker compose \
|
||||
@@ -128,150 +136,21 @@ jobs:
|
||||
--profile staging \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Deploy observability configs
|
||||
# Copies the compose file and config tree from the workspace checkout
|
||||
# into /opt/familienarchiv/ — the permanent location that persists
|
||||
# between CI runs. Containers started in the next step bind-mount
|
||||
# from there, so a future workspace wipe cannot corrupt a running
|
||||
# config file.
|
||||
#
|
||||
# obs-secrets.env is written fresh from Gitea secrets on every run so
|
||||
# Gitea is always the single source of truth for secret rotation.
|
||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||
run: |
|
||||
rm -rf /opt/familienarchiv/infra/observability
|
||||
mkdir -p /opt/familienarchiv/infra/observability
|
||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
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
|
||||
# POSTGRES_HOST is derived from the Compose project name (archiv-staging)
|
||||
# and service name (db). A project rename requires updating this value.
|
||||
- uses: ./.gitea/actions/deploy-obs
|
||||
with:
|
||||
grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
postgres_password: ${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||
postgres_host: archiv-staging-db-1
|
||||
|
||||
- name: Validate observability compose config
|
||||
# Dry-run: resolves all variable substitutions and reports any missing
|
||||
# required keys before containers start. Catches undefined variables and
|
||||
# YAML errors in config files updated by the previous step.
|
||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
config --quiet
|
||||
- uses: ./.gitea/actions/reload-caddy
|
||||
|
||||
- name: Start observability stack
|
||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||
# that survive workspace wipes between nightly runs (see ADR-016).
|
||||
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||
# obs-secrets.env second — later file wins on duplicate keys.
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Assert observability stack health
|
||||
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||
# This step explicitly asserts the five healthchecked critical services are
|
||||
# healthy before the smoke test proceeds.
|
||||
run: |
|
||||
set -e
|
||||
unhealthy=""
|
||||
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$status" != "healthy" ]; then
|
||||
echo "::error::$svc is not healthy (status: $status)"
|
||||
unhealthy="$unhealthy $svc"
|
||||
fi
|
||||
done
|
||||
[ -z "$unhealthy" ] || exit 1
|
||||
echo "All critical observability services are healthy"
|
||||
|
||||
- name: Reload Caddy
|
||||
# Apply any committed Caddyfile changes before smoke-testing the
|
||||
# public surface. Without this step, a Caddyfile edit lands in the
|
||||
# repo but Caddy keeps serving the previous config until someone
|
||||
# reloads it manually — the smoke test would then catch a stale
|
||||
# header or a still-proxied /actuator route rather than confirming
|
||||
# the current config is live.
|
||||
#
|
||||
# The runner executes job steps inside Docker containers (DooD).
|
||||
# `systemctl` is not present in container images and cannot reach
|
||||
# the host's systemd directly. We use the Docker socket (mounted
|
||||
# into every job container via runner-config.yaml) to spin up a
|
||||
# privileged sibling container in the host PID namespace; nsenter
|
||||
# then enters the host's namespaces so systemctl talks to the real
|
||||
# host systemd daemon. No sudoers entry is required — the Docker
|
||||
# socket already grants root-equivalent host access.
|
||||
#
|
||||
# Alpine is used: ~5 MB vs ~70 MB for ubuntu, no unnecessary
|
||||
# tooling, and the digest is pinned so any upstream change requires
|
||||
# an explicit bump PR. util-linux (which ships nsenter) is installed
|
||||
# at run time; apk add takes ~1 s on the warm VPS cache.
|
||||
#
|
||||
# `reload` not `restart`: reload sends SIGHUP so Caddy re-reads its
|
||||
# config in-process without dropping TLS connections. `restart`
|
||||
# would briefly stop the service, losing in-flight requests.
|
||||
#
|
||||
# If Caddy is not running this step fails fast before the smoke test
|
||||
# issues a misleading "port 443 refused" error.
|
||||
run: |
|
||||
docker run --rm --privileged --pid=host \
|
||||
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
|
||||
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
|
||||
|
||||
- name: Smoke test deployed environment
|
||||
# Healthchecks confirm containers are healthy; they do NOT confirm the
|
||||
# public surface works. This step catches: Caddy not reloaded, HSTS
|
||||
# header dropped, /actuator block bypassed.
|
||||
#
|
||||
# --resolve pins staging.raddatz.cloud to the Docker bridge gateway IP
|
||||
# (the host) so we do NOT depend on hairpin NAT on the host router.
|
||||
# 127.0.0.1 cannot be used: job containers run in bridge network mode
|
||||
# (runner-config.yaml), so 127.0.0.1 is the container's loopback, not
|
||||
# the host's. The bridge gateway IS the host; Caddy binds 0.0.0.0:443
|
||||
# and is therefore reachable from the container via that IP.
|
||||
# SNI still uses the public hostname so the TLS cert validates correctly.
|
||||
#
|
||||
# Gateway detection reads /proc/net/route (always present, no package
|
||||
# required) instead of `ip route` to avoid a dependency on iproute2.
|
||||
# Field $2=="00000000" is the default route; field $3 is the gateway as
|
||||
# a little-endian 32-bit hex value which awk decodes to dotted-decimal.
|
||||
run: |
|
||||
set -e
|
||||
HOST="staging.raddatz.cloud"
|
||||
URL="https://$HOST"
|
||||
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
||||
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||
# fail this check rather than pass it silently.
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||
# Permissions-Policy denies APIs the app does not use (camera,
|
||||
# microphone, geolocation). A regression that loosens or drops the
|
||||
# header now fails the smoke step.
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||
echo "All smoke checks passed"
|
||||
- uses: ./.gitea/actions/smoke-test
|
||||
with:
|
||||
host: staging.raddatz.cloud
|
||||
|
||||
- name: Cleanup env file
|
||||
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
||||
|
||||
@@ -23,6 +23,11 @@ name: release
|
||||
# - host ports: backend 8080, frontend 3000
|
||||
# - profile: (none) — mailpit is excluded; real SMTP relay is used
|
||||
#
|
||||
# The obs-stack deploy, Caddy reload, and smoke test are shared with
|
||||
# nightly.yml via the composite actions under .gitea/actions/ (ADR-029).
|
||||
# actions/checkout MUST stay the first step: a local `uses: ./…` action
|
||||
# only exists on disk after checkout.
|
||||
#
|
||||
# Required Gitea secrets:
|
||||
# PROD_POSTGRES_PASSWORD
|
||||
# PROD_MINIO_PASSWORD
|
||||
@@ -53,6 +58,8 @@ jobs:
|
||||
# advertised label of our single-tenant self-hosted runner.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# MUST be first: the composite actions below live under .gitea/actions/
|
||||
# and only exist on disk once the repo is checked out (ADR-029).
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Write production env file
|
||||
@@ -100,117 +107,21 @@ jobs:
|
||||
--env-file .env.production \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Deploy observability configs
|
||||
# Mirrors the nightly approach: copies obs compose file and config tree
|
||||
# to /opt/familienarchiv/ (permanent path, survives workspace wipes — ADR-016),
|
||||
# then writes obs-secrets.env fresh from Gitea secrets.
|
||||
# Non-secret config lives in infra/observability/obs.env (tracked in git).
|
||||
run: |
|
||||
rm -rf /opt/familienarchiv/infra/observability
|
||||
mkdir -p /opt/familienarchiv/infra/observability
|
||||
cp -r infra/observability/. /opt/familienarchiv/infra/observability/
|
||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
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
|
||||
# POSTGRES_HOST is derived from the Compose project name (archiv-production)
|
||||
# and service name (db). A project rename requires updating this value.
|
||||
- uses: ./.gitea/actions/deploy-obs
|
||||
with:
|
||||
grafana_admin_password: ${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||
grafana_db_password: ${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||
glitchtip_secret_key: ${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||
postgres_password: ${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||
postgres_host: archiv-production-db-1
|
||||
|
||||
- name: Validate observability compose config
|
||||
# Dry-run: resolves all variable substitutions and reports any missing
|
||||
# required keys before containers start. Catches undefined variables and
|
||||
# YAML errors in config files updated by the previous step.
|
||||
# --env-file order: obs.env first (git-tracked defaults), obs-secrets.env
|
||||
# second (CI-written secrets). Later files win on duplicate keys, so
|
||||
# obs-secrets.env overrides POSTGRES_HOST set in obs.env.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
config --quiet
|
||||
- uses: ./.gitea/actions/reload-caddy
|
||||
|
||||
- name: Start observability stack
|
||||
# Runs with absolute paths so bind mounts resolve to stable host paths
|
||||
# that survive workspace wipes between runs (see ADR-016).
|
||||
# Non-secret config from obs.env (git-tracked); secrets from obs-secrets.env
|
||||
# (written fresh from Gitea secrets above). --env-file order: obs.env first,
|
||||
# obs-secrets.env second — later file wins on duplicate keys.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
docker compose \
|
||||
-f /opt/familienarchiv/docker-compose.observability.yml \
|
||||
--env-file /opt/familienarchiv/infra/observability/obs.env \
|
||||
--env-file /opt/familienarchiv/obs-secrets.env \
|
||||
up -d --wait --remove-orphans
|
||||
|
||||
- name: Assert observability stack health
|
||||
# docker compose up --wait covers services WITH healthcheck directives only.
|
||||
# obs-promtail, obs-cadvisor, obs-node-exporter, and obs-glitchtip-worker have
|
||||
# no healthcheck — they are considered "started" as soon as the process runs.
|
||||
# This step explicitly asserts the five healthchecked critical services are
|
||||
# healthy before the smoke test proceeds.
|
||||
# Keep in sync with the equivalent step in nightly.yml (#603).
|
||||
run: |
|
||||
set -e
|
||||
unhealthy=""
|
||||
for svc in obs-loki obs-prometheus obs-grafana obs-tempo obs-glitchtip; do
|
||||
status=$(docker inspect "$svc" --format '{{.State.Health.Status}}' 2>/dev/null || echo "missing")
|
||||
if [ "$status" != "healthy" ]; then
|
||||
echo "::error::$svc is not healthy (status: $status)"
|
||||
unhealthy="$unhealthy $svc"
|
||||
fi
|
||||
done
|
||||
[ -z "$unhealthy" ] || exit 1
|
||||
echo "All critical observability services are healthy"
|
||||
|
||||
- name: Reload Caddy
|
||||
# See nightly.yml — same rationale and mechanism: DooD job containers
|
||||
# cannot call systemctl directly; nsenter via a privileged sibling
|
||||
# container reaches the host systemd. Must run after deploy (so the
|
||||
# latest Caddyfile is on disk) and before the smoke test (so the
|
||||
# public surface reflects the current config). Alpine with pinned
|
||||
# digest; reload not restart — see nightly.yml for full rationale.
|
||||
run: |
|
||||
docker run --rm --privileged --pid=host \
|
||||
alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d \
|
||||
sh -c 'apk add --no-cache util-linux -q && nsenter -t 1 -m -u -n -p -i -- /bin/systemctl reload caddy'
|
||||
|
||||
- name: Smoke test deployed environment
|
||||
# See nightly.yml — same three checks, against the prod vhost.
|
||||
# --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two
|
||||
# separate arguments; a quoted string would pass the flag and its value
|
||||
# as one token and curl would reject it as an unknown option.
|
||||
# Gateway detection via /proc/net/route — no iproute2 dependency.
|
||||
# See nightly.yml for the full network topology explanation.
|
||||
run: |
|
||||
set -e
|
||||
HOST="archiv.raddatz.cloud"
|
||||
URL="https://$HOST"
|
||||
HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route)
|
||||
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||
# fail this check rather than pass it silently.
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||
# Permissions-Policy denies APIs the app does not use (camera,
|
||||
# microphone, geolocation). A regression that loosens or drops the
|
||||
# header now fails the smoke step.
|
||||
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||
echo "All smoke checks passed"
|
||||
- uses: ./.gitea/actions/smoke-test
|
||||
with:
|
||||
host: archiv.raddatz.cloud
|
||||
|
||||
- name: Cleanup env file
|
||||
# LOAD-BEARING: `if: always()` is the linchpin of the ADR-011
|
||||
|
||||
26
CLAUDE.md
26
CLAUDE.md
@@ -86,7 +86,8 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
||||
├── filestorage/ FileService (S3/MinIO)
|
||||
├── geschichte/ Geschichte (story) domain
|
||||
├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||
│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
|
||||
├── notification/ Notification domain + SseEmitterRegistry
|
||||
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
||||
@@ -105,13 +106,15 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
|
||||
### Domain Model
|
||||
|
||||
| Entity | Table | Key relationships |
|
||||
| ----------- | ------------- | ------------------------------------------------------------------------------------- |
|
||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||
| Entity | Table | Key relationships |
|
||||
| ------------- | --------------- | --------------------------------------------------------------------------------------- |
|
||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
|
||||
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
|
||||
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
@@ -152,7 +155,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
|
||||
|
||||
### DTOs
|
||||
|
||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
|
||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs) — **except the geschichte domain**, where every response is a view (`GeschichteView`/`GeschichteSummary`/`JourneyItemView`) assembled inside the service transaction and entities never cross the controller boundary. See [ADR-036](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — lazy collections + `open-in-view: false` make serialized entities a 500 waiting to happen.
|
||||
|
||||
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
||||
|
||||
@@ -160,7 +163,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
||||
|
||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||
|
||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
|
||||
|
||||
### Security / Permissions
|
||||
|
||||
@@ -194,7 +197,6 @@ frontend/src/routes/
|
||||
│ ├── [id]/edit/ Person edit form
|
||||
│ ├── new/ Create person form
|
||||
│ └── review/ Triage view — confirm/rename/merge/delete provisional persons
|
||||
├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
|
||||
├── aktivitaeten/ Unified activity feed (Chronik)
|
||||
├── geschichten/ Stories — list, [id], [id]/edit, new
|
||||
├── stammbaum/ Family tree (Stammbaum)
|
||||
@@ -269,7 +271,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
||||
|
||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||
|
||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
||||
├── filestorage/ # FileService (S3/MinIO)
|
||||
├── geschichte/ # Geschichte (story) domain
|
||||
├── geschichte/ # Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||
│ └── journeyitem/ # JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
|
||||
├── notification/ # Notification domain + SseEmitterRegistry
|
||||
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
||||
|
||||
@@ -28,4 +28,18 @@ Authorization: Basic Gast_User gast
|
||||
###Groups
|
||||
#GET
|
||||
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>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<!-- Force WireMock's ee10 Jetty transitive deps to match Spring Boot's 12.1.8 core -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||
<artifactId>jetty-ee10-servlet</artifactId>
|
||||
<version>12.1.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||
<artifactId>jetty-ee10-servlets</artifactId>
|
||||
<version>12.1.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||
<artifactId>jetty-ee10-webapp</artifactId>
|
||||
<version>12.1.8</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-ee</artifactId>
|
||||
<version>12.1.8</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<dependencies>
|
||||
@@ -137,6 +158,12 @@
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.wiremock</groupId>
|
||||
<artifactId>wiremock-jetty12</artifactId>
|
||||
<version>3.9.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Excel Bearbeitung (Apache POI) -->
|
||||
<dependency>
|
||||
|
||||
@@ -50,10 +50,30 @@ public enum AuditKind {
|
||||
ADMIN_FORCE_LOGOUT,
|
||||
|
||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||
LOGIN_RATE_LIMITED;
|
||||
LOGIN_RATE_LIMITED,
|
||||
|
||||
// --- Documents ---
|
||||
|
||||
/** Payload: none — the deleted document's id is carried in the documentId column */
|
||||
DOCUMENT_DELETED,
|
||||
|
||||
// --- Reading Journeys (Lesereisen) ---
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */
|
||||
JOURNEY_ITEM_ADDED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_REMOVED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_NOTE_UPDATED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */
|
||||
JOURNEY_ITEMS_REORDERED;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
|
||||
JOURNEY_ITEMS_REORDERED
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,6 +177,13 @@ public class Document {
|
||||
@Builder.Default
|
||||
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
|
||||
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
|
||||
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.document;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -47,9 +46,7 @@ import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.filestorage.FileService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -138,7 +135,7 @@ public class DocumentController {
|
||||
// --- METADATA ---
|
||||
@GetMapping("/{id}")
|
||||
public Document getDocument(@PathVariable UUID id) {
|
||||
return documentService.getDocumentById(id);
|
||||
return documentService.getDocumentDetail(id);
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -171,8 +168,8 @@ public class DocumentController {
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
|
||||
documentService.deleteDocument(id);
|
||||
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id, Authentication authentication) {
|
||||
documentService.deleteDocument(id, requireUserId(authentication));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@@ -316,7 +313,8 @@ public class DocumentController {
|
||||
@RequestParam(required = false) Boolean undated,
|
||||
Authentication authentication) {
|
||||
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) {
|
||||
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_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)
|
||||
// defaults to AND, which matches the frontend default and keeps old clients working.
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
SearchFilters filters = new SearchFilters(q, from, to, senderId, receiverId, tags, tagQ, status, operator, Boolean.TRUE.equals(undated));
|
||||
Pageable pageable = PageRequest.of(page, size);
|
||||
return ResponseEntity.ok(documentService.searchDocuments(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)
|
||||
@@ -404,9 +403,7 @@ public class DocumentController {
|
||||
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
|
||||
DocumentDensityResult result = documentService.getDensity(
|
||||
new DensityFilters(q, senderId, receiverId, tags, tagQ, status, operator));
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
|
||||
.body(result);
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
// --- TRAINING LABELS ---
|
||||
@@ -445,17 +442,6 @@ public class DocumentController {
|
||||
return documentVersionService.getVersion(id, versionId);
|
||||
}
|
||||
|
||||
@GetMapping("/conversation")
|
||||
public List<Document> getConversation(
|
||||
@RequestParam UUID senderId,
|
||||
@RequestParam(required = false) UUID receiverId,
|
||||
@RequestParam(required = false) LocalDate from,
|
||||
@RequestParam(required = false) LocalDate to,
|
||||
@RequestParam(defaultValue = "DESC") String dir) {
|
||||
Sort sort = Sort.by(Sort.Direction.fromString(dir.toUpperCase()), "documentDate");
|
||||
return documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
|
||||
}
|
||||
|
||||
private UUID requireUserId(Authentication authentication) {
|
||||
return SecurityUtils.requireUserId(authentication, userService);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Published by DocumentService.deleteDocument inside its @Transactional boundary,
|
||||
* before documentRepository.deleteById fires. Listeners run synchronously in the
|
||||
* publisher's thread and transaction via plain @EventListener — this is load-bearing:
|
||||
* see ADR-038.
|
||||
*/
|
||||
public record DocumentDeletingEvent(UUID documentId) {}
|
||||
@@ -15,7 +15,6 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -37,6 +36,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
@EntityGraph("Document.list")
|
||||
Page<Document> findAll(Pageable pageable);
|
||||
|
||||
// Loader for the relevance fast path: list-item enrichment reads tags after the
|
||||
// repository call returns, so the fetch shape must match the spec-based findAll
|
||||
// overloads above. Plain findAllById carries no entity graph and must not feed
|
||||
// enrichItems — see DocumentService.relevanceSortedPageFromSql.
|
||||
@EntityGraph("Document.list")
|
||||
List<Document> findByIdIn(Collection<UUID> ids);
|
||||
|
||||
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||
@@ -58,6 +64,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
@EntityGraph("Document.full")
|
||||
List<Document> findByReceiversId(UUID receiverId);
|
||||
|
||||
|
||||
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
|
||||
List<Document> findByTags_Id(UUID tagId);
|
||||
|
||||
@@ -81,32 +88,6 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
|
||||
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
||||
|
||||
@EntityGraph("Document.full")
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
"JOIN d.receivers r " +
|
||||
"WHERE " +
|
||||
"((d.sender.id = :person1 AND r.id = :person2) " +
|
||||
" OR " +
|
||||
" (d.sender.id = :person2 AND r.id = :person1)) " +
|
||||
"AND d.documentDate BETWEEN :from AND :to")
|
||||
List<Document> findConversation(
|
||||
@Param("person1") UUID person1,
|
||||
@Param("person2") UUID person2,
|
||||
@Param("from") LocalDate from,
|
||||
@Param("to") LocalDate to,
|
||||
Sort sort);
|
||||
|
||||
@EntityGraph("Document.full")
|
||||
@Query("SELECT DISTINCT d FROM Document d " +
|
||||
"LEFT JOIN d.receivers r " +
|
||||
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
|
||||
"AND d.documentDate BETWEEN :from AND :to")
|
||||
List<Document> findSinglePersonCorrespondence(
|
||||
@Param("personId") UUID personId,
|
||||
@Param("from") LocalDate from,
|
||||
@Param("to") LocalDate to,
|
||||
Sort sort);
|
||||
|
||||
@Query(nativeQuery = true, value = """
|
||||
SELECT d.id FROM documents d
|
||||
CROSS JOIN LATERAL (
|
||||
|
||||
@@ -28,10 +28,13 @@ import org.raddatz.familienarchiv.ocr.TrainingLabel;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import jakarta.persistence.criteria.JoinType;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
@@ -68,6 +71,7 @@ import static org.raddatz.familienarchiv.document.DocumentSpecifications.*;
|
||||
public class DocumentService {
|
||||
|
||||
private final DocumentRepository documentRepository;
|
||||
private final DocumentTitleFactory documentTitleFactory;
|
||||
private final PersonService personService;
|
||||
private final FileService fileService;
|
||||
private final TagService tagService;
|
||||
@@ -77,6 +81,7 @@ public class DocumentService {
|
||||
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
private final AuditLogQueryService auditLogQueryService;
|
||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public record StoreResult(Document document, boolean isNew) {}
|
||||
|
||||
@@ -137,8 +142,10 @@ public class DocumentService {
|
||||
* <p>Implementation note: groups in memory rather than via SQL GROUP BY
|
||||
* because the existing {@link Specification} predicates compose easily
|
||||
* with {@code findAll(spec)} and the archive size (≈5k docs) keeps this
|
||||
* well under the 200ms p95 target. Cache-Control: max-age=300 on the
|
||||
* controller layer absorbs repeated browse loads.
|
||||
* well under the 200ms p95 target. The controller sets no explicit
|
||||
* Cache-Control, so the response is served fresh on every load (issue
|
||||
* #709) — the recompute is imperceptible and stale month counts after an
|
||||
* edit would be misleading on an interactive chart.
|
||||
*
|
||||
* <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,
|
||||
@@ -167,11 +174,13 @@ public class DocumentService {
|
||||
/** Loads matching documents and projects to non-null {@link LocalDate}s. */
|
||||
private List<LocalDate> loadFilteredDates(DensityFilters filters, List<UUID> ftsIds) {
|
||||
boolean hasFts = ftsIds != null;
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasFts, ftsIds, null, null,
|
||||
filters.sender(), filters.receiver(),
|
||||
filters.tags(), filters.tagQ(),
|
||||
filters.status(), filters.tagOperator(), false);
|
||||
// Density and search keep separate filter records (DensityFilters has no
|
||||
// date/undated fields); adapt to SearchFilters here to reuse buildSearchSpec.
|
||||
// Date bounds stay null and undated=false — the density path never filters by date.
|
||||
SearchFilters searchFilters = new SearchFilters(
|
||||
filters.text(), null, null, filters.sender(), filters.receiver(),
|
||||
filters.tags(), filters.tagQ(), filters.status(), filters.tagOperator(), false);
|
||||
Specification<Document> spec = buildSearchSpec(hasFts, ftsIds, searchFilters);
|
||||
return documentRepository.findAll(spec).stream()
|
||||
.map(Document::getDocumentDate)
|
||||
.filter(Objects::nonNull)
|
||||
@@ -375,10 +384,17 @@ public class DocumentService {
|
||||
|
||||
DocumentStatus statusBefore = doc.getStatus();
|
||||
|
||||
// Auto-title sync (#726): capture the machine title from the CURRENTLY-persisted state
|
||||
// BEFORE any setter runs — the setters below overwrite date/location and applyDatePrecision
|
||||
// skips nulls, so the old state must be read first. The submitted title is the catalog
|
||||
// auto-title iff it equals this; only then does it follow date/location forward.
|
||||
String autoTitleBefore = documentTitleFactory.build(doc);
|
||||
|
||||
// 1. Einfache Felder Update
|
||||
doc.setTitle(dto.getTitle());
|
||||
doc.setTitle(resolveTitle(dto.getTitle(), autoTitleBefore, doc, dto));
|
||||
doc.setDocumentDate(dto.getDocumentDate());
|
||||
applyDatePrecision(doc, dto);
|
||||
validateDateRange(doc); // guard before any save (updateDocumentTags below persists)
|
||||
doc.setLocation(dto.getLocation());
|
||||
doc.setTranscription(dto.getTranscription());
|
||||
doc.setSummary(dto.getSummary());
|
||||
@@ -419,7 +435,11 @@ public class DocumentService {
|
||||
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();
|
||||
if (fileReplaced) {
|
||||
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||
@@ -448,21 +468,92 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the three date-precision fields only when the DTO carries them.
|
||||
* A null field means "not submitted" — overwriting the stored value with null
|
||||
* would fabricate a precision the user never chose, the exact dishonesty #666
|
||||
* exists to prevent. A row with a genuinely-unknown precision must keep it when
|
||||
* an unrelated edit (e.g. a location typo) is saved.
|
||||
* Decides the title to persist on an edit (#726). The submitted title is the catalog
|
||||
* auto-title only when it equals {@code autoBefore} (built from the stored state) — an exact
|
||||
* comparison with no heuristic, relying on the edit form round-tripping the stored title
|
||||
* verbatim when untouched. A machine title is rebuilt from the new state so a corrected
|
||||
* date/location flows into it; a hand-written or freshly-typed title is kept verbatim. A blank
|
||||
* submission is never persisted (title is always present) — it falls back to the rebuilt
|
||||
* auto-title, which always carries at least the index.
|
||||
*/
|
||||
private String resolveTitle(String submitted, String autoBefore, Document doc, DocumentUpdateDTO dto) {
|
||||
if (submitted == null || submitted.isBlank()) {
|
||||
return documentTitleFactory.build(projectedState(doc, dto));
|
||||
}
|
||||
if (!Objects.equals(submitted, autoBefore)) {
|
||||
return submitted;
|
||||
}
|
||||
return documentTitleFactory.build(projectedState(doc, dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* The document state the regenerated title is built from. It is composed from the SAME
|
||||
* resolvers the real setters use — {@code documentDate}/{@code location} overwritten from the
|
||||
* DTO (a null value clears the field), precision/end/raw resolved skip-null via
|
||||
* {@link #effectivePrecision}/{@link #effectiveMetaDateEnd}/{@link #effectiveMetaDateRaw} — so
|
||||
* the projection cannot drift from {@link #updateDocument}. The index ({@code originalFilename})
|
||||
* is never touched by a metadata edit.
|
||||
*/
|
||||
private Document projectedState(Document doc, DocumentUpdateDTO dto) {
|
||||
return Document.builder()
|
||||
.originalFilename(doc.getOriginalFilename())
|
||||
.documentDate(dto.getDocumentDate())
|
||||
.location(dto.getLocation())
|
||||
.metaDatePrecision(effectivePrecision(doc, dto))
|
||||
.metaDateEnd(effectiveMetaDateEnd(doc, dto))
|
||||
.metaDateRaw(effectiveMetaDateRaw(doc, dto))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the three date-precision fields skip-null: a null DTO field means "not submitted",
|
||||
* so the stored value is kept rather than overwritten with null — which would fabricate a
|
||||
* precision the user never chose, the exact dishonesty #666 exists to prevent. Expressed via
|
||||
* the shared {@code effective*} resolvers so {@link #projectedState} stays lock-step (writing
|
||||
* the stored value back when the DTO omits a field is a harmless no-op).
|
||||
*/
|
||||
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||
if (dto.getMetaDatePrecision() != null) {
|
||||
doc.setMetaDatePrecision(dto.getMetaDatePrecision());
|
||||
doc.setMetaDatePrecision(effectivePrecision(doc, dto));
|
||||
doc.setMetaDateEnd(effectiveMetaDateEnd(doc, dto));
|
||||
doc.setMetaDateRaw(effectiveMetaDateRaw(doc, dto));
|
||||
}
|
||||
|
||||
// Skip-null date-field resolution shared by applyDatePrecision (the real setters) and
|
||||
// projectedState (the title projection) — the single rule keeps them from diverging (#726).
|
||||
private static DatePrecision effectivePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||
return dto.getMetaDatePrecision() != null ? dto.getMetaDatePrecision() : doc.getMetaDatePrecision();
|
||||
}
|
||||
|
||||
private static LocalDate effectiveMetaDateEnd(Document doc, DocumentUpdateDTO dto) {
|
||||
return dto.getMetaDateEnd() != null ? dto.getMetaDateEnd() : doc.getMetaDateEnd();
|
||||
}
|
||||
|
||||
private static String effectiveMetaDateRaw(Document doc, DocumentUpdateDTO dto) {
|
||||
return dto.getMetaDateRaw() != null ? dto.getMetaDateRaw() : doc.getMetaDateRaw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly guard for the two V69 date-range CHECK constraints, run before save so a
|
||||
* user date typo returns a clean 400 INVALID_DATE_RANGE instead of falling through to
|
||||
* the generic handler (HTTP 500 + Sentry + ERROR log). Validates the post-apply {@code doc}
|
||||
* state, not the DTO, because precision/end may have been carried over from the stored row
|
||||
* when the DTO field was null. The DB CHECK remains the backstop; this never weakens it.
|
||||
*/
|
||||
private void validateDateRange(Document doc) {
|
||||
// Mirrors chk_meta_date_end_after_start: end >= start, with null start allowed.
|
||||
// Use isBefore (equal dates are valid) — never !isAfter, which would contradict the DB's >=.
|
||||
if (doc.getMetaDatePrecision() == DatePrecision.RANGE
|
||||
&& doc.getDocumentDate() != null
|
||||
&& doc.getMetaDateEnd() != null
|
||||
&& doc.getMetaDateEnd().isBefore(doc.getDocumentDate())) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
|
||||
"meta_date_end must not be before meta_date");
|
||||
}
|
||||
if (dto.getMetaDateEnd() != null) {
|
||||
doc.setMetaDateEnd(dto.getMetaDateEnd());
|
||||
}
|
||||
if (dto.getMetaDateRaw() != null) {
|
||||
doc.setMetaDateRaw(dto.getMetaDateRaw());
|
||||
// Mirrors chk_meta_date_end_only_for_range. API-only: the edit form clears the
|
||||
// end field off-RANGE, so this branch closes the same 500 class for direct clients.
|
||||
if (doc.getMetaDateEnd() != null && doc.getMetaDatePrecision() != DatePrecision.RANGE) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE,
|
||||
"meta_date_end is only allowed when meta_date_precision is RANGE");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,18 +591,15 @@ public class DocumentService {
|
||||
* round-trip.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator,
|
||||
boolean undated) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
public List<UUID> findIdsForFilter(SearchFilters filters) {
|
||||
boolean hasText = StringUtils.hasText(filters.text());
|
||||
List<UUID> rankedIds = null;
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
||||
rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
|
||||
if (rankedIds.isEmpty()) return List.of();
|
||||
}
|
||||
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
|
||||
Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
|
||||
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
|
||||
}
|
||||
|
||||
@@ -521,23 +609,18 @@ public class DocumentService {
|
||||
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
|
||||
* full-text query returned no rows.
|
||||
*/
|
||||
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
|
||||
LocalDate from, LocalDate to,
|
||||
UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ,
|
||||
DocumentStatus status, TagOperator tagOperator,
|
||||
boolean undated) {
|
||||
boolean useOrLogic = tagOperator == TagOperator.OR;
|
||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
|
||||
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
|
||||
boolean useOrLogic = filters.tagOperator() == TagOperator.OR;
|
||||
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(filters.tags());
|
||||
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
|
||||
return Specification.where(textSpec)
|
||||
.and(isBetween(from, to))
|
||||
.and(hasSender(sender))
|
||||
.and(hasReceiver(receiver))
|
||||
.and(isBetween(filters.from(), filters.to()))
|
||||
.and(hasSender(filters.sender()))
|
||||
.and(hasReceiver(filters.receiver()))
|
||||
.and(hasTags(expandedTagSets, useOrLogic))
|
||||
.and(hasTagPartial(tagQ))
|
||||
.and(hasStatus(status))
|
||||
.and(undatedOnly(undated));
|
||||
.and(hasTagPartial(filters.tagQ()))
|
||||
.and(hasStatus(filters.status()))
|
||||
.and(undatedOnly(filters.undated()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -666,8 +749,8 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
boolean hasText = StringUtils.hasText(text);
|
||||
public DocumentSearchResult searchDocuments(SearchFilters filters, DocumentSort sort, String dir, Pageable pageable) {
|
||||
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
|
||||
@@ -677,13 +760,13 @@ public class DocumentService {
|
||||
// no date/sender/receiver/tag/status filters, and undated documents are valid
|
||||
// FTS hits already folded into the ranked page, so there is no separate undated
|
||||
// count to report here.
|
||||
if (!undated && isPureTextRelevance(hasText, sort, from, to, sender, receiver, tags, tagQ, status)) {
|
||||
return relevanceSortedPageFromSql(text, pageable);
|
||||
if (!filters.undated() && isPureTextRelevance(hasText, sort, filters)) {
|
||||
return relevanceSortedPageFromSql(filters.text(), pageable);
|
||||
}
|
||||
|
||||
List<UUID> rankedIds = null;
|
||||
if (hasText) {
|
||||
rankedIds = documentRepository.findAllMatchingIdsByFts(text);
|
||||
rankedIds = documentRepository.findAllMatchingIdsByFts(filters.text());
|
||||
// FTS matched nothing → no results and, by definition, no undated matches either.
|
||||
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
}
|
||||
@@ -691,37 +774,32 @@ public class DocumentService {
|
||||
// Global undated count for the current filter (q/tags/sender/receiver/status),
|
||||
// forcing undatedOnly(true) and IGNORING the user's "Nur undatierte" toggle so
|
||||
// it never collapses to the page slice and never double-counts (issue #668).
|
||||
long undatedCount = countUndatedForFilter(hasText, rankedIds, 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts every undated document (meta_date IS NULL) matching the active filter,
|
||||
* across all pages, independent of the undated toggle. Reuses {@link #buildSearchSpec}
|
||||
* with {@code undated=true} forced so the count tracks q/tags/sender/receiver/status.
|
||||
* A {@code from}/{@code to} range excludes undated rows by the collision rule (#668),
|
||||
* so the count is legitimately 0 inside a date range.
|
||||
* across all pages, independent of the undated toggle. The caller passes
|
||||
* {@code filters.withUndated(true)} so the count tracks q/tags/sender/receiver/status
|
||||
* regardless of the user's "Nur undatierte" toggle. A {@code from}/{@code to} range
|
||||
* excludes undated rows by the collision rule (#668), so the count is legitimately 0
|
||||
* inside a date range.
|
||||
*/
|
||||
private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds,
|
||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
|
||||
Specification<Document> undatedSpec = buildSearchSpec(
|
||||
hasText, ftsIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, true);
|
||||
private long countUndatedForFilter(boolean hasText, List<UUID> ftsIds, SearchFilters filters) {
|
||||
Specification<Document> undatedSpec = buildSearchSpec(hasText, ftsIds, filters);
|
||||
return documentRepository.count(undatedSpec);
|
||||
}
|
||||
|
||||
/** The original search dispatch — produces the page slice + totals, sans undated count. */
|
||||
private DocumentSearchResult runSearch(String text, boolean hasText, List<UUID> rankedIds,
|
||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ, DocumentStatus status,
|
||||
DocumentSort sort, String dir, TagOperator tagOperator,
|
||||
boolean undated, Pageable pageable) {
|
||||
private DocumentSearchResult runSearch(boolean hasText, List<UUID> rankedIds, SearchFilters filters,
|
||||
DocumentSort sort, String dir, Pageable pageable) {
|
||||
// The pure-text RELEVANCE fast path is handled by the caller (searchDocuments)
|
||||
// before findAllMatchingIdsByFts runs, so it never reaches here (ADR-008).
|
||||
Specification<Document> spec = buildSearchSpec(
|
||||
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated);
|
||||
Specification<Document> spec = buildSearchSpec(hasText, rankedIds, filters);
|
||||
String text = filters.text();
|
||||
|
||||
// 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
|
||||
@@ -755,12 +833,12 @@ public class DocumentService {
|
||||
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
|
||||
}
|
||||
|
||||
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort,
|
||||
LocalDate from, LocalDate to, UUID sender, UUID receiver,
|
||||
List<String> tags, String tagQ, DocumentStatus status) {
|
||||
private static boolean isPureTextRelevance(boolean hasText, DocumentSort sort, SearchFilters filters) {
|
||||
return hasText && (sort == null || sort == DocumentSort.RELEVANCE)
|
||||
&& from == null && to == null && sender == null && receiver == null
|
||||
&& (tags == null || tags.isEmpty()) && (tagQ == null || tagQ.isBlank()) && status == null;
|
||||
&& filters.from() == null && filters.to() == null
|
||||
&& filters.sender() == null && filters.receiver() == null
|
||||
&& (filters.tags() == null || filters.tags().isEmpty())
|
||||
&& (filters.tagQ() == null || filters.tagQ().isBlank()) && filters.status() == null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -775,14 +853,14 @@ public class DocumentService {
|
||||
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
||||
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
|
||||
// Preserve ts_rank order from SQL across the JPA findAllById call.
|
||||
// Preserve ts_rank order from SQL across the JPA findByIdIn call.
|
||||
Map<UUID, Integer> rankMap = new HashMap<>();
|
||||
List<UUID> pageIds = new ArrayList<>();
|
||||
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
||||
rankMap.put(ftsPage.hits().get(i).id(), i);
|
||||
pageIds.add(ftsPage.hits().get(i).id());
|
||||
}
|
||||
List<Document> docs = documentRepository.findAllById(pageIds).stream()
|
||||
List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
|
||||
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
||||
.toList();
|
||||
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
||||
@@ -901,22 +979,6 @@ public class DocumentService {
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
// 2. SPEZIALITÄT: Der Schriftwechsel
|
||||
// Findet alle Briefe ZWISCHEN zwei Personen (egal wer Sender/Empfänger war)
|
||||
public List<Document> getConversation(UUID personA, UUID personB) {
|
||||
|
||||
// Fall 1: A schreibt an B
|
||||
Specification<Document> aToB = Specification.where(hasSender(personA)).and(hasReceiver(personB));
|
||||
|
||||
// Fall 2: B schreibt an A
|
||||
Specification<Document> bToA = Specification.where(hasSender(personB)).and(hasReceiver(personA));
|
||||
|
||||
// Wir wollen (A->B) ODER (B->A)
|
||||
Specification<Document> conversation = aToB.or(bToA);
|
||||
|
||||
return documentRepository.findAll(conversation, Sort.by(Sort.Direction.ASC, "documentDate"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updateScriptType(UUID documentId, ScriptType scriptType) {
|
||||
Document doc = getDocumentById(documentId);
|
||||
@@ -946,6 +1008,41 @@ public class DocumentService {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight summary lookup for internal use (e.g. journey item append validation).
|
||||
*
|
||||
* <p><strong>Security contract — read before calling:</strong>
|
||||
* <ol>
|
||||
* <li>This method intentionally bypasses per-document scope checks and
|
||||
* tag-colour resolution. It must only be invoked after
|
||||
* {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
|
||||
* the controller layer, guaranteeing the caller is an authenticated
|
||||
* author.</li>
|
||||
* <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
|
||||
* JOURNEY-type check that fires before this call — so the method is never
|
||||
* reached for STORY-type Geschichten.</li>
|
||||
* </ol>
|
||||
* Under the current single-tenant model every authenticated author shares the
|
||||
* same document scope, so skipping per-document scope checks is safe.
|
||||
*/
|
||||
public Document findSummaryByIdInternal(UUID id) {
|
||||
return documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a document for the detail view, additionally flagging whether it has any
|
||||
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
|
||||
* existence query only runs for the single-document detail endpoint, not for the
|
||||
* many internal callers that never read the flag.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Document getDocumentDetail(UUID id) {
|
||||
Document doc = getDocumentById(id);
|
||||
doc.setHasTranscription(transcriptionBlockQueryService.hasBlocks(id));
|
||||
return doc;
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsByIds(List<UUID> ids) {
|
||||
return documentRepository.findAllById(ids);
|
||||
}
|
||||
@@ -962,13 +1059,26 @@ public class DocumentService {
|
||||
return documentRepository.findByReceiversId(receiverId);
|
||||
}
|
||||
|
||||
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
|
||||
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
||||
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
||||
if (receiverId == null) {
|
||||
return documentRepository.findSinglePersonCorrespondence(senderId, dateFrom, dateTo, sort);
|
||||
}
|
||||
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
||||
public DocumentSearchResult searchDocumentsByPersonId(UUID personId, LocalDate from, LocalDate to, Pageable pageable) {
|
||||
Person person = personService.getById(personId);
|
||||
Specification<Document> spec = buildPersonSpec(person, from, to);
|
||||
Page<Document> page = documentRepository.findAll(spec, pageable);
|
||||
List<DocumentListItem> items = enrichItems(page.getContent(), null);
|
||||
return DocumentSearchResult.paged(items, pageable, page.getTotalElements());
|
||||
}
|
||||
|
||||
private Specification<Document> buildPersonSpec(Person person, LocalDate from, LocalDate to) {
|
||||
return (root, query, cb) -> {
|
||||
if (query != null) query.distinct(true);
|
||||
var receiversJoin = root.join("receivers", JoinType.LEFT);
|
||||
var senderPredicate = cb.equal(root.get("sender"), person);
|
||||
var receiverPredicate = cb.equal(receiversJoin, person);
|
||||
var personPredicate = cb.or(senderPredicate, receiverPredicate);
|
||||
var predicates = new ArrayList<>(List.of(personPredicate));
|
||||
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
|
||||
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
|
||||
public long getIncompleteCount() {
|
||||
@@ -989,11 +1099,13 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteDocument(UUID id) {
|
||||
public void deleteDocument(UUID id, UUID actorId) {
|
||||
if (!documentRepository.existsById(id)) {
|
||||
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
||||
}
|
||||
eventPublisher.publishEvent(new DocumentDeletingEvent(id));
|
||||
documentRepository.deleteById(id);
|
||||
auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, actorId, id, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -1005,6 +1117,43 @@ public class DocumentService {
|
||||
tagService.delete(tagId);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time cleanup of already-stale auto-titles (#726, FR-003). For every document whose
|
||||
* stored title passes the {@link DocumentTitleBackfillMatcher} overwrite heuristic, rebuilds
|
||||
* the title from the row's current state and persists it only when it actually changed.
|
||||
* Idempotent: a second run rebuilds the same value and saves nothing. Hand-written prose is
|
||||
* left untouched.
|
||||
*
|
||||
* <p>Saves via {@code documentRepository.save} directly — it must NOT route through
|
||||
* {@link #updateDocument} (which versions every write), following the {@link #backfillFileHashes}
|
||||
* precedent: a mechanical rename must not snapshot the whole corpus into {@code document_versions}.
|
||||
*
|
||||
* @return the number of documents whose title was rewritten
|
||||
*/
|
||||
@Transactional
|
||||
public int backfillTitles() {
|
||||
List<Document> docs = documentRepository.findAll();
|
||||
int updated = 0;
|
||||
int skipped = 0;
|
||||
for (Document doc : docs) {
|
||||
if (!DocumentTitleBackfillMatcher.isOverwritable(
|
||||
doc.getTitle(), doc.getOriginalFilename(), doc.getLocation())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
String rebuilt = documentTitleFactory.build(doc);
|
||||
if (rebuilt.equals(doc.getTitle())) {
|
||||
skipped++; // already correct — keep idempotent, no write
|
||||
continue;
|
||||
}
|
||||
doc.setTitle(rebuilt);
|
||||
documentRepository.save(doc); // direct save, no recordVersion (mechanical rename)
|
||||
updated++;
|
||||
}
|
||||
log.info("Title backfill complete: scanned={} updated={} skipped={}", docs.size(), updated, skipped);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int backfillFileHashes() {
|
||||
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
||||
|
||||
@@ -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;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import java.time.LocalDate;
|
||||
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;
|
||||
|
||||
public boolean hasBlocks(UUID documentId) {
|
||||
return blockRepository.existsByDocumentId(documentId);
|
||||
}
|
||||
|
||||
public Map<UUID, Integer> getCompletionStats(List<UUID> documentIds) {
|
||||
if (documentIds.isEmpty()) return Map.of();
|
||||
Map<UUID, Integer> result = new HashMap<>();
|
||||
|
||||
@@ -43,6 +43,8 @@ public interface TranscriptionBlockRepository extends JpaRepository<Transcriptio
|
||||
|
||||
int countByDocumentId(UUID documentId);
|
||||
|
||||
boolean existsByDocumentId(UUID documentId);
|
||||
|
||||
@Query("""
|
||||
SELECT b FROM TranscriptionBlock b
|
||||
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) {
|
||||
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,
|
||||
/** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */
|
||||
UNSUPPORTED_FILE_TYPE,
|
||||
/** A RANGE date is invalid: meta_date_end is before meta_date, or an end date is set without RANGE precision. 400 */
|
||||
INVALID_DATE_RANGE,
|
||||
|
||||
// --- Users ---
|
||||
/** A user with the given ID or username does not exist. 404 */
|
||||
@@ -120,6 +122,22 @@ public enum ErrorCode {
|
||||
// --- Geschichten (Stories) ---
|
||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||
GESCHICHTE_NOT_FOUND,
|
||||
/** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */
|
||||
JOURNEY_ITEM_NOT_FOUND,
|
||||
/** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */
|
||||
JOURNEY_ITEM_POSITION_CONFLICT,
|
||||
/** The journey already has the maximum allowed number of items (100). 400 */
|
||||
JOURNEY_AT_CAPACITY,
|
||||
/** The document is already present in this journey — duplicate items are not allowed. 409 */
|
||||
JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
/** The type of an existing Geschichte cannot be changed via PATCH. 409 */
|
||||
GESCHICHTE_TYPE_IMMUTABLE,
|
||||
/** A journey-item note exceeds the maximum length (2000 characters). 400 */
|
||||
JOURNEY_NOTE_TOO_LONG,
|
||||
/** A Geschichte title exceeds the maximum length (255 characters — the DB column bound). 400 */
|
||||
GESCHICHTE_TITLE_TOO_LONG,
|
||||
/** A JOURNEY intro (body) exceeds the maximum length (4000 characters). 400 */
|
||||
GESCHICHTE_INTRO_TOO_LONG,
|
||||
|
||||
// --- Tags ---
|
||||
/** A tag with the given ID does not exist. 404 */
|
||||
|
||||
@@ -6,6 +6,7 @@ import io.sentry.Sentry;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
@@ -64,6 +65,45 @@ public class GlobalExceptionHandler {
|
||||
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Backstop for any database integrity violation that slips past the explicit upstream
|
||||
* guards (e.g. a future constraint, or the import path emitting a bad range). Turns it into
|
||||
* a clean 400 instead of a 500 + Sentry alert. The known date-range cases are caught upstream
|
||||
* and never reach here; this only catches the unanticipated ones — so it logs the constraint
|
||||
* NAME at WARN to stay debuggable, without re-leaking SQL and without branching the response
|
||||
* on it (the response stays generic, which is the non-brittle part).
|
||||
*/
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(DataIntegrityViolationException ex) {
|
||||
// Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which
|
||||
// constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the
|
||||
// offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault.
|
||||
String constraint = constraintNameOf(ex);
|
||||
log.warn("Rejected a request that violated a database integrity constraint: {}", constraint);
|
||||
if ("uq_journey_items_geschichte_position".equals(constraint)) {
|
||||
// DEFERRABLE INITIALLY DEFERRED — fires at commit when concurrent appends/reorders collide
|
||||
return ResponseEntity.status(409)
|
||||
.body(new ErrorResponse(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT,
|
||||
"A position conflict was detected — another request modified this journey simultaneously"));
|
||||
}
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the offending constraint's name from the cause chain, or {@code "unknown"}.
|
||||
* Reads only the name (a non-sensitive schema identifier) — never the SQL or the values.
|
||||
*/
|
||||
private static String constraintNameOf(Throwable ex) {
|
||||
for (Throwable t = ex; t != null && t != t.getCause(); t = t.getCause()) {
|
||||
if (t instanceof org.hibernate.exception.ConstraintViolationException cve
|
||||
&& cve.getConstraintName() != null) {
|
||||
return cve.getConstraintName();
|
||||
}
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
||||
Sentry.captureException(ex);
|
||||
|
||||
@@ -5,12 +5,14 @@ import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -40,6 +42,12 @@ public class Geschichte {
|
||||
@Builder.Default
|
||||
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private GeschichteType type = GeschichteType.STORY;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "author_id")
|
||||
private AppUser author;
|
||||
@@ -51,12 +59,18 @@ public class Geschichte {
|
||||
@Builder.Default
|
||||
private Set<Person> persons = new HashSet<>();
|
||||
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
@JoinTable(name = "geschichten_documents",
|
||||
joinColumns = @JoinColumn(name = "geschichte_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
||||
// LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.md. open-in-view is FALSE
|
||||
// (application.yaml), so this collection is DEAD at Jackson serialization time unless
|
||||
// explicitly initialized inside the service transaction. getById() is
|
||||
// @Transactional(readOnly=true) AND calls getItems().size() to force-init before return.
|
||||
// list() must NOT serialize items at all — it returns a GeschichteSummary projection.
|
||||
// This is the first List ("bag") collection on Geschichte — adding a second EAGER/
|
||||
// fetch-joined List here will throw MultipleBagFetchException at boot.
|
||||
@OneToMany(mappedBy = "geschichte", cascade = CascadeType.ALL, orphanRemoval = true,
|
||||
fetch = FetchType.LAZY)
|
||||
@OrderBy("position ASC")
|
||||
@Builder.Default
|
||||
private Set<Document> documents = new HashSet<>();
|
||||
private List<JourneyItem> items = new ArrayList<>();
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(updatable = false)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -14,6 +16,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -28,9 +31,10 @@ import java.util.UUID;
|
||||
public class GeschichteController {
|
||||
|
||||
private final GeschichteService geschichteService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
@GetMapping
|
||||
public List<Geschichte> list(
|
||||
public List<GeschichteSummary> list(
|
||||
@RequestParam(required = false) GeschichteStatus status,
|
||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
||||
@RequestParam(required = false) UUID documentId,
|
||||
@@ -43,20 +47,20 @@ public class GeschichteController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Geschichte getById(@PathVariable UUID id) {
|
||||
return geschichteService.getById(id);
|
||||
public GeschichteView getById(@PathVariable UUID id) {
|
||||
return geschichteService.getView(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||
Geschichte created = geschichteService.create(dto);
|
||||
public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||
GeschichteView created = geschichteService.create(dto);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||
public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||
return geschichteService.update(id, dto);
|
||||
}
|
||||
|
||||
@@ -66,4 +70,45 @@ public class GeschichteController {
|
||||
geschichteService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ─── JourneyItem CRUD ────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/{id}/items")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<JourneyItemView> appendItem(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyItemCreateDTO dto) {
|
||||
JourneyItemView view = journeyItemService.append(id, dto);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(view);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public JourneyItemView updateItemNote(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId,
|
||||
@RequestBody JourneyItemUpdateDTO dto) {
|
||||
return journeyItemService.updateNote(id, itemId, dto);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<Void> deleteItem(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId) {
|
||||
journeyItemService.delete(id, itemId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/items/reorder")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
@Operation(
|
||||
summary = "Reorder journey items",
|
||||
description = "itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request."
|
||||
)
|
||||
public List<JourneyItemView> reorderItems(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyReorderDTO dto) {
|
||||
return journeyItemService.reorder(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Thin read-only service owning {@link GeschichteRepository}.
|
||||
* Exists so that {@code JourneyItemService} can check Geschichte existence
|
||||
* and load Geschichte instances without holding a direct reference to the
|
||||
* Geschichte repository (cross-domain repository access is not allowed per
|
||||
* layering rules).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GeschichteQueryService {
|
||||
|
||||
private final GeschichteRepository geschichteRepository;
|
||||
|
||||
public boolean existsById(UUID id) {
|
||||
return geschichteRepository.existsById(id);
|
||||
}
|
||||
|
||||
public Optional<Geschichte> findById(UUID id) {
|
||||
return geschichteRepository.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,47 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
||||
|
||||
/**
|
||||
* Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false).
|
||||
*
|
||||
* <p>Status clamp: callers must pass the effective status (PUBLISHED for readers,
|
||||
* raw status for BLOG_WRITE users). authorId restricts to own drafts when effective=DRAFT.
|
||||
*
|
||||
* <p>Person filter: personCount=0 disables the filter. When personCount>0, the story must
|
||||
* be associated with ALL person ids in personIds (AND-semantics via counting subquery).
|
||||
* Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
|
||||
g.author AS author, g.publishedAt AS publishedAt, g.updatedAt AS updatedAt, g.body AS body
|
||||
FROM Geschichte g
|
||||
WHERE g.status = :effectiveStatus
|
||||
AND (:authorId IS NULL OR g.author.id = :authorId)
|
||||
AND (:personCount = 0 OR
|
||||
(SELECT COUNT(DISTINCT p.id)
|
||||
FROM Geschichte g2 JOIN g2.persons p
|
||||
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
|
||||
AND (:documentId IS NULL OR
|
||||
EXISTS (SELECT 1 FROM JourneyItem ji
|
||||
WHERE ji.geschichte = g AND ji.document.id = :documentId))
|
||||
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
|
||||
""")
|
||||
List<GeschichteSummary> findSummaries(
|
||||
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
|
||||
@Param("authorId") UUID authorId,
|
||||
@Param("personIds") Collection<UUID> personIds,
|
||||
@Param("personCount") long personCount,
|
||||
@Param("documentId") UUID documentId);
|
||||
}
|
||||
|
||||
@@ -4,28 +4,23 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.owasp.html.HtmlPolicyBuilder;
|
||||
import org.owasp.html.PolicyFactory;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -41,6 +36,7 @@ public class GeschichteService {
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final UserService userService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
/**
|
||||
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
||||
@@ -54,12 +50,22 @@ public class GeschichteService {
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
private static final int MAX_LIMIT = 200;
|
||||
|
||||
// Matches the geschichten.title VARCHAR(255) column (V58) — the service check
|
||||
// turns what would be a DB-level 500 into a friendly 400.
|
||||
static final int MAX_TITLE_LENGTH = 255;
|
||||
// JOURNEY intros travel the verbatim (unsanitized) write path, so they get the
|
||||
// same three-layer bound as journey notes: frontend maxlength, this check, and
|
||||
// the V75 CHECK constraint. STORY bodies are sanitized Tiptap HTML and stay
|
||||
// unbounded on purpose.
|
||||
static final int MAX_INTRO_LENGTH = 4000;
|
||||
|
||||
// ─── Read API ────────────────────────────────────────────────────────────
|
||||
|
||||
public long countPublished() {
|
||||
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Geschichte getById(UUID id) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
@@ -72,24 +78,57 @@ public class GeschichteService {
|
||||
return g;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public GeschichteView getView(UUID id) {
|
||||
Geschichte g = getById(id);
|
||||
List<JourneyItemView> items = journeyItemService.getItems(id);
|
||||
return toView(g, items);
|
||||
}
|
||||
|
||||
GeschichteView toView(Geschichte g, List<JourneyItemView> items) {
|
||||
AppUser author = g.getAuthor();
|
||||
GeschichteView.AuthorView authorView = null;
|
||||
if (author != null) {
|
||||
String displayName = PersonNameFormatter.join(author.getFirstName(), author.getLastName());
|
||||
if (displayName.isBlank()) displayName = "[Unbekannt]";
|
||||
authorView = new GeschichteView.AuthorView(author.getId(), displayName);
|
||||
}
|
||||
Set<GeschichteView.PersonView> personViews = new HashSet<>();
|
||||
for (Person p : g.getPersons()) {
|
||||
personViews.add(new GeschichteView.PersonView(p.getId(), p.getFirstName(), p.getLastName()));
|
||||
}
|
||||
return new GeschichteView(
|
||||
g.getId(), g.getTitle(), g.getBody(),
|
||||
g.getStatus(), g.getType(),
|
||||
authorView, personViews,
|
||||
items,
|
||||
g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
|
||||
* must be associated with every person id supplied. An empty or null list applies no
|
||||
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
|
||||
*
|
||||
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
|
||||
* LazyInitializationException on the non-transactional list path.
|
||||
*/
|
||||
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||
|
||||
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
|
||||
Specification<Geschichte> spec = Specification.allOf(
|
||||
GeschichteSpecifications.hasStatus(effective),
|
||||
GeschichteSpecifications.hasAuthor(authorId),
|
||||
GeschichteSpecifications.hasAllPersons(personIds),
|
||||
GeschichteSpecifications.hasDocument(documentId),
|
||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
||||
);
|
||||
return geschichteRepository.findAll(spec, Sort.unsorted())
|
||||
|
||||
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
|
||||
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
|
||||
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
|
||||
? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
|
||||
: personIds;
|
||||
long personCount = (personIds == null) ? 0 : personIds.size();
|
||||
|
||||
return geschichteRepository
|
||||
.findSummaries(effective, authorId, safePersonIds, personCount, documentId)
|
||||
.stream()
|
||||
.limit(safeLimit)
|
||||
.toList();
|
||||
@@ -97,46 +136,57 @@ public class GeschichteService {
|
||||
|
||||
// ─── Write API ───────────────────────────────────────────────────────────
|
||||
|
||||
// Write methods return GeschichteView, never the entity: Jackson serializes after
|
||||
// the transaction closed, where the lazy items collection is a dead proxy.
|
||||
// The view is assembled in-transaction, so no force-init tricks are needed.
|
||||
|
||||
@Transactional
|
||||
public Geschichte create(GeschichteUpdateDTO dto) {
|
||||
public GeschichteView create(GeschichteUpdateDTO dto) {
|
||||
requireTitle(dto.getTitle());
|
||||
GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
|
||||
Geschichte g = Geschichte.builder()
|
||||
.title(dto.getTitle().trim())
|
||||
.body(sanitize(dto.getBody()))
|
||||
.body(bodyForType(type, dto.getBody()))
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(type)
|
||||
.author(currentUser())
|
||||
.persons(resolvePersons(dto.getPersonIds()))
|
||||
.documents(resolveDocuments(dto.getDocumentIds()))
|
||||
.build();
|
||||
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
||||
g.setStatus(GeschichteStatus.PUBLISHED);
|
||||
g.setPublishedAt(LocalDateTime.now());
|
||||
}
|
||||
return geschichteRepository.save(g);
|
||||
Geschichte saved = geschichteRepository.save(g);
|
||||
// A freshly created Geschichte has no items by construction — items are only
|
||||
// addable via the separate /items endpoints. Revisit if a create DTO ever
|
||||
// accepts initial items.
|
||||
return toView(saved, List.of());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
|
||||
public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
||||
if (dto.getType() != null && dto.getType() != g.getType()) {
|
||||
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE,
|
||||
"The type of a Geschichte cannot be changed after creation");
|
||||
}
|
||||
if (dto.getTitle() != null) {
|
||||
requireTitle(dto.getTitle());
|
||||
g.setTitle(dto.getTitle().trim());
|
||||
}
|
||||
if (dto.getBody() != null) {
|
||||
g.setBody(sanitize(dto.getBody()));
|
||||
g.setBody(bodyForType(g.getType(), dto.getBody()));
|
||||
}
|
||||
if (dto.getPersonIds() != null) {
|
||||
g.setPersons(resolvePersons(dto.getPersonIds()));
|
||||
}
|
||||
if (dto.getDocumentIds() != null) {
|
||||
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
|
||||
}
|
||||
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
||||
applyStatusTransition(g, dto.getStatus());
|
||||
}
|
||||
return geschichteRepository.save(g);
|
||||
Geschichte saved = geschichteRepository.save(g);
|
||||
return toView(saved, journeyItemService.getItems(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -164,6 +214,27 @@ public class GeschichteService {
|
||||
throw DomainException.badRequest(
|
||||
ErrorCode.VALIDATION_ERROR, "Title is required");
|
||||
}
|
||||
if (title.trim().length() > MAX_TITLE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.GESCHICHTE_TITLE_TOO_LONG,
|
||||
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer.
|
||||
* JOURNEY intros are plain text: the reader renders them via Svelte text
|
||||
* interpolation (never {@code {@html}}), so entity-encoding them here would
|
||||
* corrupt content ("&" → "&") and re-encode on every editor round-trip.
|
||||
*/
|
||||
private String bodyForType(GeschichteType type, String body) {
|
||||
if (type != GeschichteType.JOURNEY) {
|
||||
return sanitize(body);
|
||||
}
|
||||
if (body != null && body.length() > MAX_INTRO_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.GESCHICHTE_INTRO_TOO_LONG,
|
||||
"Intro exceeds maximum length of " + MAX_INTRO_LENGTH + " characters");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private String sanitize(String body) {
|
||||
@@ -176,15 +247,6 @@ public class GeschichteService {
|
||||
return new LinkedHashSet<>(personService.getAllById(ids));
|
||||
}
|
||||
|
||||
private Set<Document> resolveDocuments(List<UUID> ids) {
|
||||
if (ids == null || ids.isEmpty()) return new HashSet<>();
|
||||
Set<Document> out = new LinkedHashSet<>();
|
||||
for (UUID id : ids) {
|
||||
out.add(documentService.getDocumentById(id));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private AppUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
|
||||
@@ -6,9 +6,6 @@ import jakarta.persistence.criteria.Join;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
import jakarta.persistence.criteria.Subquery;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
@@ -48,12 +45,7 @@ public final class GeschichteSpecifications {
|
||||
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
||||
}
|
||||
|
||||
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
||||
return (root, query, cb) -> {
|
||||
if (documentId == null) return null;
|
||||
return cb.exists(documentSubquery(root, query, cb, documentId));
|
||||
};
|
||||
}
|
||||
// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands
|
||||
|
||||
/**
|
||||
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
|
||||
@@ -84,14 +76,4 @@ public final class GeschichteSpecifications {
|
||||
return sub;
|
||||
}
|
||||
|
||||
private static Subquery<UUID> documentSubquery(
|
||||
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
|
||||
Subquery<UUID> sub = query.subquery(UUID.class);
|
||||
Root<Geschichte> subRoot = sub.from(Geschichte.class);
|
||||
Join<Geschichte, Document> documents = subRoot.join("documents");
|
||||
sub.select(subRoot.get("id"))
|
||||
.where(cb.equal(subRoot.get("id"), root.get("id")),
|
||||
cb.equal(documents.get("id"), documentId));
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* List-projection for the /api/geschichten grid. Never carries items — avoids
|
||||
* LazyInitializationException (open-in-view: false) and prevents Cartesian joins.
|
||||
* Mirrors the PersonSummaryDTO precedent.
|
||||
*
|
||||
* <p>Field set: exactly what the live grid card renders (title, author byline, body excerpt,
|
||||
* publishedAt, status, type). Does NOT carry items or persons.
|
||||
*/
|
||||
public interface GeschichteSummary {
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
UUID getId();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String getTitle();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
GeschichteStatus getStatus();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
GeschichteType getType();
|
||||
|
||||
/** Nested closed projection — exposes only the fields the grid card needs. */
|
||||
AuthorSummary getAuthor();
|
||||
|
||||
LocalDateTime getPublishedAt();
|
||||
|
||||
/** Always set (@UpdateTimestamp) — drives "bearbeitet vor X" on dashboard cards. */
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
LocalDateTime getUpdatedAt();
|
||||
|
||||
String getBody();
|
||||
|
||||
/** Author projection — names only; never email or group memberships (same rule as GeschichteView.AuthorView). */
|
||||
interface AuthorSummary {
|
||||
String getFirstName();
|
||||
String getLastName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
public enum GeschichteType {
|
||||
STORY,
|
||||
JOURNEY
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.Data;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -16,6 +15,6 @@ public class GeschichteUpdateDTO {
|
||||
private String title;
|
||||
private String body;
|
||||
private GeschichteStatus status;
|
||||
private GeschichteType type;
|
||||
private List<UUID> personIds;
|
||||
private List<UUID> documentIds;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Detail-view response for GET /api/geschichten/{id}. Assembled by
|
||||
* GeschichteService — never the raw entity (author AppUser graph must not leak).
|
||||
* items is always present (both STORY and JOURNEY); empty list for stories with no items.
|
||||
*/
|
||||
public record GeschichteView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
String body,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteStatus status,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteType type,
|
||||
AuthorView author,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<PersonView> persons,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<JourneyItemView> items,
|
||||
LocalDateTime publishedAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt
|
||||
) {
|
||||
/** Summarised author — exposes only id and displayName, never email or group memberships. */
|
||||
public record AuthorView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName
|
||||
) {}
|
||||
|
||||
/** Summarised person — exposes only id, firstName, and lastName. No admin-only fields. */
|
||||
public record PersonView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
String firstName,
|
||||
String lastName
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
/**
|
||||
* Utility for joining a person's first and last name into a display string.
|
||||
* Centralises the logic that was previously duplicated across GeschichteService
|
||||
* and JourneyItemService.
|
||||
*/
|
||||
public class PersonNameFormatter {
|
||||
|
||||
private PersonNameFormatter() {
|
||||
// utility class — no instances
|
||||
}
|
||||
|
||||
public static String join(String firstName, String lastName) {
|
||||
String first = firstName != null ? firstName.trim() : "";
|
||||
String last = lastName != null ? lastName.trim() : "";
|
||||
if (first.isEmpty() && last.isEmpty()) return "";
|
||||
if (first.isEmpty()) return last;
|
||||
if (last.isEmpty()) return first;
|
||||
return first + " " + last;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Lean read-model view of a Document for embedding in JourneyItemView.
|
||||
* Built by JourneyItemService.toSummary(Document) — never serialised from
|
||||
* a JPA entity to avoid LazyInitializationException and tag-color overhead.
|
||||
*/
|
||||
public record DocumentSummary(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
LocalDate documentDate,
|
||||
LocalDate documentDateEnd,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision datePrecision,
|
||||
String senderName,
|
||||
String receiverName,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Integer receiverCount
|
||||
) {}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "journey_items")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class JourneyItem {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "geschichte_id", nullable = false)
|
||||
@JsonIgnore
|
||||
private Geschichte geschichte;
|
||||
|
||||
// Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order
|
||||
// — the editor is responsible for keeping them distinct.
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int position;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "document_id")
|
||||
@JsonIgnore
|
||||
private Document document;
|
||||
|
||||
// CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email
|
||||
// renderer MUST escape this; only Svelte {note} is auto-safe.
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String note;
|
||||
|
||||
// JPA uses field access — this getter is not persisted. Jackson serializes it as documentId.
|
||||
// Exposing only the UUID prevents circular references and large nested payloads.
|
||||
public UUID getDocumentId() {
|
||||
return document != null ? document.getId() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Input for POST /api/geschichten/{id}/items. Both fields optional; at least one must be present. */
|
||||
@Data
|
||||
public class JourneyItemCreateDTO {
|
||||
private UUID documentId;
|
||||
private String note;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.document.DocumentDeletingEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
class JourneyItemDocumentDeleteListener {
|
||||
|
||||
private final JourneyItemRepository journeyItemRepository;
|
||||
|
||||
/**
|
||||
* Plain @EventListener — runs synchronously in the publisher's thread and transaction.
|
||||
* Load-bearing choice: AFTER_COMMIT would fire after the FK ON DELETE SET NULL has
|
||||
* already 500'd; @Async would run outside the delete transaction (breaks AC-5 rollback).
|
||||
* See ADR-038. DocumentService cannot call JourneyItemService directly because
|
||||
* Spring Framework 7 prohibits the resulting constructor-injection cycle.
|
||||
*/
|
||||
@EventListener
|
||||
void onDocumentDeleting(DocumentDeletingEvent event) {
|
||||
int deleted = journeyItemRepository.deleteNoteLessByDocumentId(event.documentId());
|
||||
if (deleted > 0) {
|
||||
log.warn("Cascade-deleted {} note-less journey item(s) for document {}", deleted, event.documentId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID> {
|
||||
|
||||
/** Returns items ordered by position ASC for the read-model assembly path. */
|
||||
List<JourneyItem> findByGeschichteIdOrderByPosition(UUID geschichteId);
|
||||
|
||||
/** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */
|
||||
Optional<JourneyItem> findByIdAndGeschichteId(UUID id, UUID geschichteId);
|
||||
|
||||
/** Returns only the IDs — used for set-equality check in reorder. */
|
||||
@Query("SELECT i.id FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||
Set<UUID> findIdsByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||
|
||||
/** MAX position for computing the next append position; returns empty when journey has no items. */
|
||||
@Query("SELECT MAX(i.position) FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||
Optional<Integer> findMaxPositionByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||
|
||||
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
|
||||
long countByGeschichteId(UUID geschichteId);
|
||||
|
||||
/**
|
||||
* Dedup guard: true when the document is already linked to this journey.
|
||||
* Explicit JPQL, not a derived query: the transient {@code getDocumentId()}
|
||||
* getter on JourneyItem makes Spring Data resolve the derived path as a
|
||||
* direct {@code documentId} attribute, which Hibernate cannot map.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COUNT(i) > 0 FROM JourneyItem i
|
||||
WHERE i.geschichte.id = :geschichteId AND i.document.id = :documentId
|
||||
""")
|
||||
boolean existsByGeschichteIdAndDocumentId(
|
||||
@Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId);
|
||||
|
||||
/**
|
||||
* Deletes note-less items (note IS NULL or note = '') linked to the given document.
|
||||
* Used by JourneyItemDocumentDeleteListener before the document row is removed, so
|
||||
* the FK ON DELETE SET NULL never fires on rows that would violate chk_journey_item_not_empty.
|
||||
* Explicit JPQL — same trap as existsByGeschichteIdAndDocumentId: the transient
|
||||
* getDocumentId() getter makes Spring Data unable to resolve a derived query path.
|
||||
* clearAutomatically = true invalidates the L1 cache so AC-2's "note-carrying survives"
|
||||
* assertion never reads a stale entity. flushAutomatically = true makes the
|
||||
* flush-before-delete contract explicit rather than relying on Hibernate AUTO flush mode.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
|
||||
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
|
||||
|
||||
/**
|
||||
* Loads journey items with their linked Document in a single JOIN FETCH query,
|
||||
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
|
||||
* lazily for each item. Items without a document (note-only) are included via
|
||||
* LEFT JOIN. Ordered by position ASC.
|
||||
*/
|
||||
@Query("SELECT ji FROM JourneyItem ji LEFT JOIN FETCH ji.document WHERE ji.geschichte.id = :geschichteId ORDER BY ji.position ASC")
|
||||
List<JourneyItem> findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JourneyItemService {
|
||||
|
||||
static final int MAX_ITEMS = 100;
|
||||
static final int POSITION_STEP = 10;
|
||||
// 2000 per the editor spec — frontend maxlength and the i18n error message agree (#793).
|
||||
static final int MAX_NOTE_LENGTH = 2000;
|
||||
|
||||
private final JourneyItemRepository journeyItemRepository;
|
||||
private final GeschichteQueryService geschichteQueryService;
|
||||
private final DocumentService documentService;
|
||||
private final AuditService auditService;
|
||||
private final UserService userService;
|
||||
|
||||
@Transactional
|
||||
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
|
||||
Geschichte g = geschichteQueryService.findById(geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||
"Geschichte not found: " + geschichteId));
|
||||
|
||||
long count = journeyItemRepository.countByGeschichteId(geschichteId);
|
||||
if (count >= MAX_ITEMS) {
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
|
||||
"Journey has reached the maximum of 100 items");
|
||||
}
|
||||
|
||||
String note = normalizeNote(dto.getNote());
|
||||
|
||||
if (dto.getDocumentId() == null && note == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"At least one of documentId or note must be provided");
|
||||
}
|
||||
|
||||
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
|
||||
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||
}
|
||||
|
||||
Document doc = null;
|
||||
if (dto.getDocumentId() != null) {
|
||||
if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
"Document already in journey: " + dto.getDocumentId());
|
||||
}
|
||||
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
|
||||
}
|
||||
|
||||
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
|
||||
.map(max -> max + POSITION_STEP)
|
||||
.orElse(POSITION_STEP);
|
||||
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(g)
|
||||
.position(nextPosition)
|
||||
.document(doc)
|
||||
.note(note)
|
||||
.build();
|
||||
// saveAndFlush so the partial unique index on (geschichte_id, document_id)
|
||||
// fires here, not at commit — two concurrent appends can both pass the
|
||||
// exists() pre-check above, and the index is the atomic backstop (V74).
|
||||
JourneyItem saved;
|
||||
try {
|
||||
saved = journeyItemRepository.saveAndFlush(item);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// Only the dedup index earns the friendly 409 — any other integrity
|
||||
// failure (e.g. an FK violation on a concurrently deleted document)
|
||||
// must not be mislabeled as "already added".
|
||||
if (!isDuplicateDocumentViolation(e)) {
|
||||
throw e;
|
||||
}
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
"Document already in journey: " + dto.getDocumentId());
|
||||
}
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", saved.getId()));
|
||||
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
|
||||
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||
"Journey item not found: " + itemId));
|
||||
|
||||
// null = field absent from JSON → no-op
|
||||
Optional<String> noteField = dto.getNote();
|
||||
if (noteField == null) {
|
||||
return toView(item);
|
||||
}
|
||||
|
||||
String note = normalizeNote(noteField.orElse(null));
|
||||
|
||||
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
|
||||
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||
}
|
||||
|
||||
if (note == null && item.getDocumentId() == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Cannot clear note on an item that has no linked document");
|
||||
}
|
||||
|
||||
item.setNote(note);
|
||||
JourneyItem saved = journeyItemRepository.save(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(UUID geschichteId, UUID itemId) {
|
||||
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||
"Journey item not found: " + itemId));
|
||||
|
||||
journeyItemRepository.delete(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
|
||||
if (!geschichteQueryService.existsById(geschichteId)) {
|
||||
throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||
"Geschichte not found: " + geschichteId);
|
||||
}
|
||||
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
|
||||
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
|
||||
|
||||
if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Duplicate item IDs in reorder request");
|
||||
}
|
||||
|
||||
if (!existingIds.equals(new HashSet<>(requestedIds))) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Requested item IDs do not match the journey's existing items");
|
||||
}
|
||||
|
||||
if (requestedIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
|
||||
Map<UUID, JourneyItem> itemMap = new HashMap<>();
|
||||
for (JourneyItem item : items) {
|
||||
itemMap.put(item.getId(), item);
|
||||
}
|
||||
|
||||
List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
|
||||
for (int i = 0; i < requestedIds.size(); i++) {
|
||||
JourneyItem item = itemMap.get(requestedIds.get(i));
|
||||
item.setPosition((i + 1) * POSITION_STEP);
|
||||
toSave.add(item);
|
||||
}
|
||||
List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));
|
||||
|
||||
return reordered.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
public List<JourneyItemView> getItems(UUID geschichteId) {
|
||||
return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId)
|
||||
.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
DocumentSummary toSummary(Document doc) {
|
||||
String senderName = buildSenderName(doc);
|
||||
Set<Person> receivers = doc.getReceivers();
|
||||
String receiverName = buildCanonicalReceiverName(receivers);
|
||||
|
||||
return new DocumentSummary(
|
||||
doc.getId(),
|
||||
doc.getTitle(),
|
||||
doc.getDocumentDate(),
|
||||
doc.getMetaDateEnd(),
|
||||
doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
|
||||
senderName,
|
||||
receiverName,
|
||||
receivers != null ? receivers.size() : 0
|
||||
);
|
||||
}
|
||||
|
||||
JourneyItemView toView(JourneyItem item) {
|
||||
DocumentSummary docSummary = null;
|
||||
Document doc = item.getDocument();
|
||||
if (doc != null) {
|
||||
docSummary = toSummary(doc);
|
||||
}
|
||||
return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
|
||||
}
|
||||
|
||||
private static String buildSenderName(Document doc) {
|
||||
Person sender = doc.getSender();
|
||||
if (sender != null) {
|
||||
String name = PersonNameFormatter.join(sender.getFirstName(), sender.getLastName());
|
||||
if (!name.isBlank()) return name;
|
||||
}
|
||||
String senderText = doc.getSenderText();
|
||||
return (senderText != null && !senderText.isBlank()) ? senderText : null;
|
||||
}
|
||||
|
||||
private static String buildCanonicalReceiverName(Set<Person> receivers) {
|
||||
if (receivers == null || receivers.isEmpty()) return null;
|
||||
return receivers.stream()
|
||||
.min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
|
||||
.map(p -> {
|
||||
String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName());
|
||||
return name.isBlank() ? null : name;
|
||||
})
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
|
||||
Throwable cause = e.getMostSpecificCause();
|
||||
String message = cause != null ? cause.getMessage() : e.getMessage();
|
||||
return message != null && message.contains("uq_journey_items_geschichte_document");
|
||||
}
|
||||
|
||||
private static String normalizeNote(String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
private static String sortKey(String s) {
|
||||
return s != null ? s : "";
|
||||
}
|
||||
|
||||
private AppUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
return userService.findByEmail(auth.getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Input for PATCH /api/geschichten/{id}/items/{itemId}.
|
||||
* Three-way semantics via Optional<String>:
|
||||
* null → field absent from JSON → leave note unchanged
|
||||
* Optional.empty() → {"note": null} → clear the note
|
||||
* Optional.of("x") → {"note": "x"} → set the note
|
||||
*
|
||||
* Jackson 3.x maps JSON null to Optional.empty(); absent fields keep the Java default (null).
|
||||
*/
|
||||
@Data
|
||||
public class JourneyItemUpdateDTO {
|
||||
private Optional<String> note = null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Read-model response for a JourneyItem. Never the JPA entity (which has a
|
||||
* Geschichte back-reference that would leak / hit LazyInitializationException).
|
||||
*/
|
||||
public record JourneyItemView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
|
||||
DocumentSummary document,
|
||||
String note
|
||||
) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Input for PUT /api/geschichten/{id}/items/reorder. */
|
||||
@Data
|
||||
public class JourneyReorderDTO {
|
||||
private List<UUID> itemIds;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
@@ -74,6 +75,7 @@ public class DocumentImporter {
|
||||
Pattern.compile("[A-Za-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]{1,4}-+\\d+x?");
|
||||
|
||||
private final DocumentService documentService;
|
||||
private final DocumentTitleFactory documentTitleFactory;
|
||||
private final PersonService personService;
|
||||
private final TagService tagService;
|
||||
private final S3Client s3Client;
|
||||
@@ -181,7 +183,7 @@ public class DocumentImporter {
|
||||
applyAttribution(doc, row);
|
||||
applyDates(doc, row);
|
||||
applyAuthoritativeAssociations(doc, row);
|
||||
applyFileMetadata(doc, s3Key, contentType, status, index);
|
||||
applyFileMetadata(doc, s3Key, contentType, status);
|
||||
applyComputedFlags(doc);
|
||||
return doc;
|
||||
}
|
||||
@@ -217,14 +219,15 @@ public class DocumentImporter {
|
||||
attachTag(doc, row.get("tags"));
|
||||
}
|
||||
|
||||
// S3 key, content type, status, and the index-derived title.
|
||||
// S3 key, content type, status, and the index-derived title. The title formula lives in
|
||||
// the document package's DocumentTitleFactory (single source of truth, #726); by this point
|
||||
// applyDates has populated the date/location and originalFilename carries the index.
|
||||
private void applyFileMetadata(Document doc, String s3Key, String contentType,
|
||||
DocumentStatus status, String index) {
|
||||
DocumentStatus status) {
|
||||
doc.setStatus(status);
|
||||
doc.setFilePath(s3Key);
|
||||
doc.setContentType(contentType);
|
||||
doc.setTitle(buildTitle(index, doc.getDocumentDate(), doc.getMetaDatePrecision(),
|
||||
doc.getMetaDateEnd(), doc.getMetaDateRaw(), doc.getLocation()));
|
||||
doc.setTitle(documentTitleFactory.build(doc));
|
||||
}
|
||||
|
||||
// metadataComplete: a document counts as fully described if any of the three "who/when"
|
||||
@@ -235,20 +238,6 @@ public class DocumentImporter {
|
||||
|| !doc.getReceivers().isEmpty());
|
||||
}
|
||||
|
||||
// The title carries the date at the HONEST precision (never a fabricated day) via the
|
||||
// shared DocumentTitleFormatter, plus the location — kept under 20 lines by delegating.
|
||||
private static String buildTitle(String index, LocalDate date, DatePrecision precision,
|
||||
LocalDate end, String raw, String location) {
|
||||
StringBuilder title = new StringBuilder(index);
|
||||
if (date != null && precision != DatePrecision.UNKNOWN) {
|
||||
title.append(" – ").append(DocumentTitleFormatter.formatTitleDate(date, precision, end, raw));
|
||||
}
|
||||
if (location != null && !location.isBlank()) {
|
||||
title.append(" – ").append(location);
|
||||
}
|
||||
return title.toString();
|
||||
}
|
||||
|
||||
// ─── attribution routing — register-first, always retain raw ─────────────────────
|
||||
|
||||
private Person resolveSender(String slug, String rawName) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Result of {@link PersonService#resolveByName(String)}: candidate persons split by name-match
|
||||
* strength. {@code direct} = every query token is a whole-token match across the person's name
|
||||
* components (alias/maiden-name aware); {@code partial} = matched the substring fetch but is not
|
||||
* direct. The vocabulary is deliberately name-match strength ({@code direct}/{@code partial}), not
|
||||
* the search layer's resolved/ambiguous buckets — the caller maps these into its own outcome.
|
||||
*/
|
||||
public record NameMatches(List<Person> direct, List<Person> partial) {
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||
List<Person> searchByName(@Param("query") String query);
|
||||
|
||||
@@ -29,14 +30,36 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// Stammbaum-Knoten: alle Personen mit family_member = true.
|
||||
List<Person> findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
|
||||
// Lookup by full alias string, used during ODS mass import
|
||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||
// Exact-case alias lookup — the first resolution step in findOrCreateByAlias.
|
||||
// Case-colliding aliases across persons (müller / Müller) are valid human labels, NOT
|
||||
// duplicates: source_ref is the stable identity (ADR-025/033), alias is editable. Do NOT
|
||||
// add a unique(lower(alias)) constraint — see ADR-033.
|
||||
Optional<Person> findByAlias(String alias);
|
||||
|
||||
// Plural case-insensitive alias lookup — the fallback step. Returns ALL case-folding
|
||||
// siblings so the service can pick a deterministic one (lowest id) instead of letting a
|
||||
// derived Optional<…>IgnoreCase throw NonUniqueResultException. See ADR-033.
|
||||
List<Person> findAllByAliasIgnoreCase(String alias);
|
||||
|
||||
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
|
||||
Optional<Person> findBySourceRef(String sourceRef);
|
||||
|
||||
// Exact first+last name match, used for filename-based sender lookup
|
||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
||||
// Exact-case first+last name match — the first step of filename-based sender resolution.
|
||||
// Explicit `=` (HQL, not a derived query) so a null firstName binds as `first_name = NULL`
|
||||
// — never a match — instead of the derived-query fold to `first_name IS NULL`, which would
|
||||
// pull a last-name-only row in as a sender (a provenance defect). See ADR-033.
|
||||
@Query("SELECT p FROM Person p WHERE p.firstName = :firstName AND p.lastName = :lastName")
|
||||
Optional<Person> findByFirstNameAndLastName(@Param("firstName") String firstName,
|
||||
@Param("lastName") String lastName);
|
||||
|
||||
// Plural case-insensitive first+last name match — lets findByName bail to empty on 2+ matches
|
||||
// instead of letting a derived Optional<…>IgnoreCase throw NonUniqueResultException. Same
|
||||
// null fail-closed guarantee as above: LOWER(:firstName) is NULL for a null arg, so a null
|
||||
// first name resolves to no match (not first_name IS NULL widening). See ADR-033.
|
||||
@Query("SELECT p FROM Person p WHERE LOWER(p.firstName) = LOWER(:firstName) "
|
||||
+ "AND LOWER(p.lastName) = LOWER(:lastName)")
|
||||
List<Person> findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(@Param("firstName") String firstName,
|
||||
@Param("lastName") String lastName);
|
||||
|
||||
// --- PersonSummaryDTO with document count ---
|
||||
|
||||
@@ -189,18 +212,15 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
List<Person> findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q);
|
||||
|
||||
// --- Merge helpers (native SQL to bypass JPA entity layer) ---
|
||||
// clearAutomatically + flushAutomatically keep the L1 cache from desyncing: these bulk
|
||||
// updates run beneath Hibernate, and mergePersons follows them with a deleteById whose
|
||||
// ON DELETE CASCADE (V71) also fires beneath the session.
|
||||
|
||||
@Modifying
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = 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);
|
||||
|
||||
// Used by deletePerson: detach a deleted person from documents they sent, so the hard
|
||||
// delete cannot orphan a documents.sender_id FK (the column is nullable).
|
||||
@Modifying
|
||||
@Query(value = "UPDATE documents SET sender_id = NULL WHERE sender_id = :source", nativeQuery = true)
|
||||
void reassignSenderToNull(@Param("source") UUID source);
|
||||
|
||||
@Modifying
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query(value = """
|
||||
INSERT INTO document_receivers (document_id, person_id)
|
||||
SELECT document_id, :target FROM document_receivers
|
||||
@@ -210,8 +230,4 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
)
|
||||
""", nativeQuery = true)
|
||||
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
|
||||
|
||||
@Modifying
|
||||
@Query(value = "DELETE FROM document_receivers WHERE person_id = :source", nativeQuery = true)
|
||||
void deleteReceiverReferences(@Param("source") UUID source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
@@ -23,11 +30,20 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PersonService {
|
||||
|
||||
// Co-located with the fetch loop that owns them (issue #763). MAX_TOKENS caps the number of
|
||||
// unindexed leading-wildcard LIKE scans per name — a DoS control, not just perf. MAX_CANDIDATES
|
||||
// bounds each result bucket and is applied AFTER classification so a direct match that sorts
|
||||
// past position 10 among partials is never discarded.
|
||||
private static final int MAX_TOKENS = 8;
|
||||
private static final int MAX_CANDIDATES = 10;
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
private final PersonNameAliasRepository aliasRepository;
|
||||
|
||||
@@ -68,15 +84,13 @@ public class PersonService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-deletes a person used by triage. Detaches the person from any documents they
|
||||
* sent (nulls sender_id) and from any received-document references first, so the delete
|
||||
* cannot orphan an FK and fail with a 500.
|
||||
* Hard-deletes a person used by triage. Referential integrity is enforced by the database
|
||||
* (V71's {@code ON DELETE} constraints: sender_id {@code SET NULL}, receiver and @-mention
|
||||
* rows {@code CASCADE}), so the service stays thin — it only verifies existence then deletes.
|
||||
*/
|
||||
@Transactional
|
||||
public void deletePerson(UUID id) {
|
||||
getById(id);
|
||||
personRepository.reassignSenderToNull(id);
|
||||
personRepository.deleteReceiverReferences(id);
|
||||
personRepository.deleteById(id);
|
||||
}
|
||||
|
||||
@@ -100,6 +114,96 @@ public class PersonService {
|
||||
return personRepository.findAllById(ids);
|
||||
}
|
||||
|
||||
public List<Person> findByDisplayNameContaining(String fragment) {
|
||||
return personRepository.searchByName(fragment);
|
||||
}
|
||||
|
||||
// Name-match tokenizer (issue #763): lowercase, split on whitespace/hyphen/apostrophe,
|
||||
// drop empties. Applied symmetrically to the query and to every candidate name component so
|
||||
// that "Anna-Maria" and "Anna Maria" tokenize alike. Order-preserving for deterministic tests.
|
||||
static Set<String> tokenize(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return Set.of();
|
||||
}
|
||||
LinkedHashSet<String> tokens = new LinkedHashSet<>();
|
||||
for (String part : raw.toLowerCase(Locale.ROOT).split("[\\s\\-']+")) {
|
||||
if (!part.isEmpty()) {
|
||||
tokens.add(part);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an extracted person name into {@link NameMatches} by name-match strength.
|
||||
* Orchestrates tokenize → cap → fetch pool → classify → cap-after-classify. Read-only
|
||||
* transaction keeps the Hibernate session open so each candidate's lazy {@code nameAliases}
|
||||
* are reachable during classification (see ADR-022).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public NameMatches resolveByName(String name) {
|
||||
Set<String> queryTokens = capTokens(tokenize(name));
|
||||
if (queryTokens.isEmpty()) {
|
||||
log.debug("resolveByName outcome=no-match tokens=0");
|
||||
return new NameMatches(List.of(), List.of());
|
||||
}
|
||||
return classify(fetchPool(queryTokens), queryTokens);
|
||||
}
|
||||
|
||||
private Set<String> capTokens(Set<String> tokens) {
|
||||
return tokens.stream().limit(MAX_TOKENS).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private List<Person> fetchPool(Set<String> queryTokens) {
|
||||
LinkedHashMap<UUID, Person> pool = new LinkedHashMap<>();
|
||||
for (String token : queryTokens) {
|
||||
for (Person candidate : findByDisplayNameContaining(token)) {
|
||||
pool.putIfAbsent(candidate.getId(), candidate);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(pool.values());
|
||||
}
|
||||
|
||||
private NameMatches classify(List<Person> pool, Set<String> queryTokens) {
|
||||
List<Person> direct = new ArrayList<>();
|
||||
List<Person> partial = new ArrayList<>();
|
||||
for (Person candidate : pool) {
|
||||
if (personTokens(candidate).containsAll(queryTokens)) {
|
||||
direct.add(candidate);
|
||||
} else {
|
||||
partial.add(candidate);
|
||||
}
|
||||
}
|
||||
List<Person> cappedDirect = cap(direct);
|
||||
List<Person> cappedPartial = cap(partial);
|
||||
log.debug("resolveByName outcome={} tokens={}", outcome(cappedDirect, cappedPartial), queryTokens.size());
|
||||
return new NameMatches(cappedDirect, cappedPartial);
|
||||
}
|
||||
|
||||
private static Set<String> personTokens(Person person) {
|
||||
Set<String> tokens = new LinkedHashSet<>();
|
||||
tokens.addAll(tokenize(person.getFirstName()));
|
||||
tokens.addAll(tokenize(person.getLastName()));
|
||||
tokens.addAll(tokenize(person.getAlias()));
|
||||
tokens.addAll(tokenize(person.getTitle()));
|
||||
for (PersonNameAlias alias : person.getNameAliases()) {
|
||||
tokens.addAll(tokenize(alias.getFirstName()));
|
||||
tokens.addAll(tokenize(alias.getLastName()));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static List<Person> cap(List<Person> people) {
|
||||
return people.size() > MAX_CANDIDATES ? people.subList(0, MAX_CANDIDATES) : people;
|
||||
}
|
||||
|
||||
private static String outcome(List<Person> direct, List<Person> partial) {
|
||||
if (direct.size() == 1) return "direct=1";
|
||||
if (direct.size() >= 2) return "direct>=2";
|
||||
if (!partial.isEmpty()) return "partial-only";
|
||||
return "no-match";
|
||||
}
|
||||
|
||||
public List<Person> findAllFamilyMembers() {
|
||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
}
|
||||
@@ -112,7 +216,19 @@ public class PersonService {
|
||||
}
|
||||
|
||||
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. */
|
||||
@@ -127,32 +243,45 @@ public class PersonService {
|
||||
PersonType type = PersonTypeClassifier.classify(alias);
|
||||
if (type == PersonType.SKIP) return null;
|
||||
|
||||
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
|
||||
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
|
||||
return personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.lastName(alias)
|
||||
.personType(type)
|
||||
.build());
|
||||
}
|
||||
// Aliases differing only by case (müller / Müller) are valid distinct persons, not
|
||||
// duplicates, so a CASE-COLLISION must not throw: exact-case first, then the lowest-id
|
||||
// case-insensitive sibling, then create. Mirrors the tag path — see ADR-033.
|
||||
// Scope (#731): "ambiguous" means case-insensitive. Two BYTE-IDENTICAL same-case aliases
|
||||
// are a true data anomaly out of scope here; the exact Optional below would surface that
|
||||
// as the opaque INTERNAL_ERROR (never a wrong row), not silently pick one.
|
||||
Optional<Person> exact = personRepository.findByAlias(alias);
|
||||
if (exact.isPresent()) return exact.get(); // exact-case wins
|
||||
List<Person> caseInsensitive = personRepository.findAllByAliasIgnoreCase(alias);
|
||||
if (!caseInsensitive.isEmpty()) {
|
||||
return caseInsensitive.stream().min(Comparator.comparing(Person::getId)).orElseThrow(); // deterministic tie-break — list is non-empty, never throws
|
||||
}
|
||||
|
||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||
Person person = personRepository.save(Person.builder()
|
||||
// Create-when-absent: institution/group keep the full label in lastName; a person name
|
||||
// is split and a maiden name (geb. …) becomes a MAIDEN_NAME alias.
|
||||
if (type == PersonType.INSTITUTION || type == PersonType.GROUP) {
|
||||
return personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.firstName(split.firstName())
|
||||
.lastName(split.lastName())
|
||||
.lastName(alias)
|
||||
.personType(type)
|
||||
.build());
|
||||
if (split.maidenName() != null) {
|
||||
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(person)
|
||||
.lastName(split.maidenName())
|
||||
.type(PersonNameAliasType.MAIDEN_NAME)
|
||||
.sortOrder(nextSortOrder)
|
||||
.build());
|
||||
}
|
||||
return person;
|
||||
});
|
||||
}
|
||||
|
||||
PersonNameParser.SplitName split = PersonNameParser.split(alias);
|
||||
Person person = personRepository.save(Person.builder()
|
||||
.alias(alias)
|
||||
.firstName(split.firstName())
|
||||
.lastName(split.lastName())
|
||||
.build());
|
||||
if (split.maidenName() != null) {
|
||||
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(person)
|
||||
.lastName(split.maidenName())
|
||||
.type(PersonNameAliasType.MAIDEN_NAME)
|
||||
.sortOrder(nextSortOrder)
|
||||
.build());
|
||||
}
|
||||
return person;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,6 +424,12 @@ public class PersonService {
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the source person into the target, then deletes the source. Sender references move
|
||||
* to the target; receiver references the target lacks are inserted. The source's leftover
|
||||
* receiver join rows are not deleted explicitly — they cascade-drop via V71's
|
||||
* {@code ON DELETE CASCADE} on {@code document_receivers.person_id} when the source is deleted.
|
||||
*/
|
||||
@Transactional
|
||||
public void mergePersons(UUID sourceId, UUID targetId) {
|
||||
if (sourceId.equals(targetId)) {
|
||||
@@ -311,9 +446,7 @@ public class PersonService {
|
||||
// Add target as receiver where source is receiver but target is not yet
|
||||
personRepository.insertMissingReceiverReference(sourceId, targetId);
|
||||
|
||||
// Remove all remaining source receiver references (duplicates already handled)
|
||||
personRepository.deleteReceiverReferences(sourceId);
|
||||
|
||||
// Source's remaining receiver rows cascade-drop via V71's ON DELETE CASCADE.
|
||||
personRepository.deleteById(sourceId);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
|
||||
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
|
||||
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
||||
| `findAll(String q)` | document, dashboard | List all persons |
|
||||
| `findByName(String firstName, String lastName)` | document | Typeahead search |
|
||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally |
|
||||
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
|
||||
| `resolveByName(String name)` | search | NL-search name resolution returning `NameMatches` (direct vs partial). Token/word-boundary, alias-aware matching so a single direct match auto-selects even when looser substring hits coexist ("Clara Cram" vs "Clara Cramer"). See #763. |
|
||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
|
||||
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
||||
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
||||
| `count()` | dashboard | Total person count for stats |
|
||||
|
||||
@@ -7,6 +7,13 @@ Hierarchical document categories. Tags form a tree via a self-referencing `paren
|
||||
Entity: `Tag` (self-referencing `parent_id` tree).
|
||||
Features: tag CRUD, hierarchical deletion (cascade to descendants), tag typeahead, admin tag management (rename, reparent, merge).
|
||||
|
||||
## Tag tree counts (`getTagTree`)
|
||||
|
||||
`GET /api/tags/tree` returns each node with **two** document counts, from two aggregate queries (no N+1):
|
||||
|
||||
- `documentCount` — documents tagged with that **exact** tag (direct). Read by the admin surfaces (sidebar tree, merge preview, delete-impact guard), which describe direct-document operations.
|
||||
- `subtreeDocumentCount` — **distinct** documents tagged with that tag **or any descendant** (subtree rollup, recursive-CTE closure, depth guard ≤50). Read by the reader surfaces (`/themen` page, dashboard `ThemenWidget`) so the box number matches what `/documents?tag=X` actually finds.
|
||||
|
||||
## What this domain does NOT own
|
||||
|
||||
- Documents — the `document_tags` join table is on the document side. `Tag` does not hold document references.
|
||||
|
||||
@@ -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).
|
||||
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)
|
||||
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.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -45,6 +46,10 @@ public class TagService {
|
||||
return enrichWithRelatives(matched);
|
||||
}
|
||||
|
||||
public List<Tag> findByNameContaining(String fragment) {
|
||||
return tagRepository.findByNameContainingIgnoreCase(fragment);
|
||||
}
|
||||
|
||||
public Tag getById(UUID id) {
|
||||
return tagRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
||||
@@ -55,10 +60,21 @@ public class TagService {
|
||||
return tagRepository.findBySourceRef(sourceRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a tag name to a single tag, creating one when absent. Never throws on case-insensitive
|
||||
* collisions: names that differ only by case are valid distinct nodes in the canonical tree (a
|
||||
* parent and its same-named lowercase child), so resolution prefers an exact-case match, then
|
||||
* falls back to the lowest-id case-insensitive match, then creates. See #730.
|
||||
*/
|
||||
public Tag findOrCreate(String name) {
|
||||
String cleanName = name.trim();
|
||||
return tagRepository.findByNameIgnoreCase(cleanName)
|
||||
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
|
||||
Optional<Tag> exact = tagRepository.findByName(cleanName);
|
||||
if (exact.isPresent()) return exact.get(); // exact-case wins (edit round-trip replays the stored name)
|
||||
List<Tag> caseInsensitive = tagRepository.findAllByNameIgnoreCase(cleanName);
|
||||
if (!caseInsensitive.isEmpty()) {
|
||||
return caseInsensitive.stream().min(Comparator.comparing(Tag::getId)).orElseThrow(); // deterministic tie-break by id — list is non-empty, never throws
|
||||
}
|
||||
return tagRepository.save(Tag.builder().name(cleanName).build()); // create-when-absent (orphan tag: null sourceRef/parentId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,19 +188,27 @@ public class TagService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all tags assembled into a tree with document counts per node.
|
||||
* Uses a single aggregate query to avoid N+1 behaviour.
|
||||
* NOTE: document counts are global per tag, not scoped to any search filter.
|
||||
* The tree endpoint is only used for the admin sidebar, so this is intentional.
|
||||
* Returns all tags assembled into a tree, each node carrying two counts:
|
||||
* {@code documentCount} — documents tagged with that exact tag (direct) — and
|
||||
* {@code subtreeDocumentCount} — distinct documents tagged with that tag or any descendant
|
||||
* (subtree rollup). Each count comes from one aggregate query (no N+1).
|
||||
* NOTE: counts are global per tag, not scoped to any search filter.
|
||||
* Consumed by the reader surfaces (/themen page, dashboard ThemenWidget — which read the
|
||||
* subtree rollup) as well as the admin sidebar and tag operation previews (which read the
|
||||
* direct count).
|
||||
*/
|
||||
public List<TagTreeNodeDTO> getTagTree() {
|
||||
List<Tag> all = tagRepository.findAll();
|
||||
Map<UUID, Long> counts = tagRepository.findDocumentCountsPerTag().stream()
|
||||
.collect(Collectors.toMap(
|
||||
TagRepository.TagCount::getTagId,
|
||||
TagRepository.TagCount::getCount
|
||||
));
|
||||
return buildTree(all, counts);
|
||||
Map<UUID, Long> counts = toCountMap(tagRepository.findDocumentCountsPerTag());
|
||||
Map<UUID, Long> subtreeCounts = toCountMap(tagRepository.findSubtreeDocumentCountsPerTag());
|
||||
return buildTree(all, counts, subtreeCounts);
|
||||
}
|
||||
|
||||
private static Map<UUID, Long> toCountMap(List<TagRepository.TagCount> counts) {
|
||||
return counts.stream().collect(Collectors.toMap(
|
||||
TagRepository.TagCount::getTagId,
|
||||
TagRepository.TagCount::getCount
|
||||
));
|
||||
}
|
||||
|
||||
// ─── private helpers ─────────────────────────────────────────────────────
|
||||
@@ -259,12 +283,14 @@ public class TagService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts) {
|
||||
private List<TagTreeNodeDTO> buildTree(List<Tag> tags, Map<UUID, Long> counts,
|
||||
Map<UUID, Long> subtreeCounts) {
|
||||
Map<UUID, TagTreeNodeDTO> nodeById = new LinkedHashMap<>();
|
||||
for (Tag tag : tags) {
|
||||
int documentCount = counts.getOrDefault(tag.getId(), 0L).intValue();
|
||||
int subtreeDocumentCount = subtreeCounts.getOrDefault(tag.getId(), 0L).intValue();
|
||||
nodeById.put(tag.getId(), new TagTreeNodeDTO(
|
||||
tag.getId(), tag.getName(), tag.getColor(), documentCount,
|
||||
tag.getId(), tag.getName(), tag.getColor(), documentCount, subtreeDocumentCount,
|
||||
new ArrayList<>(), tag.getParentId()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -10,5 +10,8 @@ public record TagTreeNodeDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String name,
|
||||
String color,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int documentCount,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
description = "Distinct documents tagged with this tag or any descendant tag (subtree rollup)")
|
||||
int subtreeDocumentCount,
|
||||
List<TagTreeNodeDTO> children,
|
||||
@Schema(description = "Parent tag ID, null for root tags") UUID parentId) {}
|
||||
|
||||
@@ -51,6 +51,12 @@ public class AdminController {
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
|
||||
@PostMapping("/backfill-titles")
|
||||
public ResponseEntity<BackfillResult> backfillTitles() {
|
||||
int count = documentService.backfillTitles();
|
||||
return ResponseEntity.ok(new BackfillResult(count));
|
||||
}
|
||||
|
||||
@PostMapping("/generate-thumbnails")
|
||||
public ResponseEntity<ThumbnailBackfillService.BackfillStatus> generateThumbnails() {
|
||||
thumbnailBackfillService.runBackfillAsync();
|
||||
|
||||
@@ -11,3 +11,4 @@ springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Move person-delete referential integrity from application code into the database (#684).
|
||||
--
|
||||
-- Before this migration, PersonService.deletePerson nulled documents.sender_id and removed
|
||||
-- document_receivers rows in Java before deleting the person, because the two V1 FKs into
|
||||
-- persons had no ON DELETE behaviour. Any other delete path (a future endpoint, a manual
|
||||
-- psql, a batch job) could still orphan rows or 500. This migration makes the database the
|
||||
-- single source of truth so a person delete is safe from every path.
|
||||
--
|
||||
-- Cascade boundary: the cascade stays STRICTLY at the join/reference layer and NEVER reaches
|
||||
-- documents rows — a cascade into documents would destroy historical letters. sender_id is
|
||||
-- SET NULL (documents.senderText preserves the raw textual attribution); the receiver join
|
||||
-- row and the @-mention sidecar row are dropped.
|
||||
--
|
||||
-- No NOT VALID + VALIDATE two-step: these tables are small (thousands of rows → sub-second
|
||||
-- ACCESS EXCLUSIVE lock). Do NOT copy this drop-and-recreate pattern onto a large table.
|
||||
--
|
||||
-- Not audit-logged: a DB ON DELETE cascade runs below AuditService — a known, accepted trade.
|
||||
-- The person-delete action itself is still logged at the service layer.
|
||||
|
||||
-- documents.sender_id → ON DELETE SET NULL (deleted sender clears the link; the document survives).
|
||||
ALTER TABLE public.documents
|
||||
DROP CONSTRAINT fkl5xhww7es3b4um01vmly4y18m,
|
||||
ADD CONSTRAINT fkl5xhww7es3b4um01vmly4y18m
|
||||
FOREIGN KEY (sender_id) REFERENCES public.persons(id) ON DELETE SET NULL;
|
||||
|
||||
-- document_receivers.person_id → ON DELETE CASCADE (drop the join row), the symmetric
|
||||
-- completion of V14, which added the same to the document_id side of this table.
|
||||
ALTER TABLE public.document_receivers
|
||||
DROP CONSTRAINT fkcg7r68qvosqricx1betgrlt7s,
|
||||
ADD CONSTRAINT fkcg7r68qvosqricx1betgrlt7s
|
||||
FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE;
|
||||
|
||||
-- Soft reference fix: transcription_block_mentioned_persons.person_id was a UUID with no FK
|
||||
-- (V56), so deleting a person left dangling mention rows. Give it a real FK with CASCADE.
|
||||
-- This reverses V56's deliberate "no FK on person_id" choice — that comment is now historical
|
||||
-- but is intentionally left untouched, because editing an already-applied migration changes its
|
||||
-- Flyway checksum and would fail validateOnMigrate in prod. ADR-032 is the authoritative record.
|
||||
-- Clean up pre-existing orphans first — production likely holds dangling rows because the old
|
||||
-- deletePerson never cleaned mention rows, and the ADD CONSTRAINT validation scan fails on them.
|
||||
-- A DO block with RAISE NOTICE surfaces the purge count: Flyway runs each statement via JDBC
|
||||
-- and discards a trailing SELECT's result set, so a "SELECT count(*)" would log nothing.
|
||||
DO $$
|
||||
DECLARE removed int;
|
||||
BEGIN
|
||||
DELETE FROM transcription_block_mentioned_persons m
|
||||
WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id);
|
||||
GET DIAGNOSTICS removed = ROW_COUNT;
|
||||
RAISE NOTICE 'V71 orphaned_mention_rows_removed=%', removed;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE public.transcription_block_mentioned_persons
|
||||
ADD CONSTRAINT fk_tbmp_person
|
||||
FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Production pre-requisite — run BEFORE applying this migration:
|
||||
-- docker exec familienarchiv-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
|
||||
-- -c "SELECT COUNT(DISTINCT (geschichte_id, document_id)) FROM geschichten_documents;"'
|
||||
-- docker exec familienarchiv-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \
|
||||
-- --table=geschichten_documents \
|
||||
-- -f /tmp/pre_v72_backup_'"$(date +%Y%m%d)"'.sql'
|
||||
-- Take the dump even if geschichten_documents is empty — it captures the table DEFINITION
|
||||
-- for emergency reconstruction. The DROP TABLE is the only irreversible step; the
|
||||
-- INSERT...SELECT is a no-op when there is no data. No DDL rollback path exists after commit.
|
||||
--
|
||||
-- REVERSE PROCEDURE (if V72 must be rolled back): restore the pre-V72 dump, then re-derive
|
||||
-- the junction from the new table:
|
||||
-- INSERT INTO geschichten_documents (geschichte_id, document_id)
|
||||
-- SELECT geschichte_id, document_id FROM journey_items WHERE document_id IS NOT NULL;
|
||||
-- Note: the reconstructed junction FK is ON DELETE CASCADE per the original V58
|
||||
-- (NOT the new SET NULL of journey_items). Domain FKs target app_users (post-V60) —
|
||||
-- do NOT hand-type V58's verbatim "REFERENCES users" DDL nor copy journey_items' SET NULL
|
||||
-- into the reconstructed junction.
|
||||
--
|
||||
-- ASSUMPTION AS-001: The old geschichten_documents was an unordered Set — no curator order
|
||||
-- existed. Ordering by meta_date is a plausible default a Lesereise lets curators
|
||||
-- re-sequence. This is not a requirement; it is the best available approximation.
|
||||
--
|
||||
-- ASSUMPTION AS-002: Existing published Geschichten (STORYs) render the related-letters block;
|
||||
-- this block visibly degrades to generic links (loss of per-document title AND date) for ALL
|
||||
-- current readers during the stub window. Accepted because the reader follow-on is the
|
||||
-- next-priority blocking dependency.
|
||||
|
||||
-- Step 1: Add type discriminator column to geschichten
|
||||
ALTER TABLE geschichten
|
||||
ADD COLUMN type VARCHAR(50) DEFAULT 'STORY' NOT NULL;
|
||||
|
||||
-- Step 2: Create journey_items table
|
||||
CREATE TABLE journey_items (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
geschichte_id UUID NOT NULL,
|
||||
position INT NOT NULL,
|
||||
document_id UUID,
|
||||
note TEXT,
|
||||
CONSTRAINT pk_journey_items PRIMARY KEY (id),
|
||||
CONSTRAINT fk_journey_items_geschichte
|
||||
FOREIGN KEY (geschichte_id) REFERENCES geschichten(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_journey_items_document
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_journey_item_not_empty
|
||||
CHECK (document_id IS NOT NULL OR note IS NOT NULL)
|
||||
);
|
||||
|
||||
-- Step 3: Index for ordered retrieval by geschichte + position
|
||||
CREATE INDEX idx_journey_items_geschichte_position
|
||||
ON journey_items (geschichte_id, position ASC);
|
||||
|
||||
-- Step 4: Migrate geschichten_documents → journey_items
|
||||
-- Positions are multiples of 1000 (headroom for drag-reorder).
|
||||
-- Ordered by meta_date ASC NULLS LAST, then documents.id ASC as deterministic tiebreaker.
|
||||
-- SELECT DISTINCT guards against duplicate junction rows producing duplicate journey items.
|
||||
INSERT INTO journey_items (id, geschichte_id, position, document_id)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
gd.geschichte_id,
|
||||
(ROW_NUMBER() OVER (
|
||||
PARTITION BY gd.geschichte_id
|
||||
ORDER BY d.meta_date ASC NULLS LAST, d.id ASC
|
||||
) * 1000)::INT AS position,
|
||||
gd.document_id
|
||||
FROM (
|
||||
SELECT DISTINCT geschichte_id, document_id
|
||||
FROM geschichten_documents
|
||||
) gd
|
||||
LEFT JOIN documents d ON d.id = gd.document_id;
|
||||
|
||||
-- Step 5: Drop the old junction table (irreversible — take the pg_dump first)
|
||||
DROP TABLE geschichten_documents;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Adds the two constraints that V72 deferred:
|
||||
-- 1. UNIQUE(geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
|
||||
-- Allows mid-transaction position swaps during reorder (checked at COMMIT, not per-row).
|
||||
-- Requires transaction-level or session-level connection pooling (prod uses PgBouncer
|
||||
-- in transaction mode — correct today; a future switch to statement-level would silently
|
||||
-- break deferred checking at COMMIT).
|
||||
-- 2. CHECK (position > 0) — defense against off-by-one in the append path.
|
||||
--
|
||||
-- MUST run in a single transaction; Flyway's default per-migration transaction satisfies this.
|
||||
-- Do NOT add executeInTransaction=false or any callback that splits this migration.
|
||||
|
||||
ALTER TABLE journey_items
|
||||
ADD CONSTRAINT uq_journey_items_geschichte_position
|
||||
UNIQUE (geschichte_id, position)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
ALTER TABLE journey_items
|
||||
ADD CONSTRAINT chk_journey_item_position
|
||||
CHECK (position > 0);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Two constraints the service-level checks need as atomic backstops:
|
||||
--
|
||||
-- 1. Partial unique index on (geschichte_id, document_id): the append dedup
|
||||
-- guard is a check-then-insert (existsByGeschichteIdAndDocumentId), so two
|
||||
-- concurrent appends of the same document can both pass the pre-check.
|
||||
-- The index rejects the second INSERT; JourneyItemService.append translates
|
||||
-- the DataIntegrityViolationException into the same 409
|
||||
-- JOURNEY_DOCUMENT_ALREADY_ADDED as the friendly pre-check.
|
||||
-- Partial (WHERE document_id IS NOT NULL) — note-only interludes must not collide.
|
||||
--
|
||||
-- 2. CHECK on note length: mirrors chk_text_length on transcription_blocks.
|
||||
-- 2000 is the spec'd limit — JourneyItemService.MAX_NOTE_LENGTH, the frontend
|
||||
-- maxlength, and the i18n error message all agree (#793).
|
||||
--
|
||||
-- Defensive cleanup first: a database that served writes on the base branch
|
||||
-- (no dedup guard, MAX_NOTE_LENGTH = 5000) can hold rows that would make the
|
||||
-- DDL below fail mid-migration and boot-loop the backend on a failed Flyway
|
||||
-- row. Both statements are no-ops on a clean database.
|
||||
|
||||
-- Keep the earliest-positioned row of each (geschichte, document) pair.
|
||||
DELETE FROM journey_items a
|
||||
USING journey_items b
|
||||
WHERE a.geschichte_id = b.geschichte_id
|
||||
AND a.document_id = b.document_id
|
||||
AND a.document_id IS NOT NULL
|
||||
AND a.position > b.position;
|
||||
|
||||
-- Clamp over-long notes written under the old 5000-char service limit.
|
||||
UPDATE journey_items SET note = left(note, 2000) WHERE length(note) > 2000;
|
||||
|
||||
CREATE UNIQUE INDEX uq_journey_items_geschichte_document
|
||||
ON journey_items (geschichte_id, document_id)
|
||||
WHERE document_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE journey_items
|
||||
ADD CONSTRAINT chk_journey_item_note_length
|
||||
CHECK (note IS NULL OR length(note) <= 2000);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- JOURNEY intros travel the verbatim (unsanitized) write path and get the same
|
||||
-- three-layer bound as journey notes: frontend maxlength, the
|
||||
-- GeschichteService.MAX_INTRO_LENGTH check, and this CHECK as the atomic backstop.
|
||||
-- STORY bodies are sanitized Tiptap HTML and stay unbounded on purpose.
|
||||
--
|
||||
-- The title needs no CHECK here — VARCHAR(255) (V58) already bounds it at the
|
||||
-- DB layer; the service-level check exists to turn that 500 into a friendly 400.
|
||||
|
||||
-- Defensive clamp first: intros written before this migration may exceed the
|
||||
-- cap. No-op on a clean database.
|
||||
UPDATE geschichten SET body = left(body, 4000)
|
||||
WHERE type = 'JOURNEY' AND length(body) > 4000;
|
||||
|
||||
ALTER TABLE geschichten
|
||||
ADD CONSTRAINT chk_geschichte_journey_intro_length
|
||||
CHECK (type <> 'JOURNEY' OR body IS NULL OR length(body) <= 4000);
|
||||
@@ -38,7 +38,6 @@ import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -76,7 +75,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
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()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
@@ -88,7 +87,7 @@ class DocumentControllerTest {
|
||||
void search_undatedTrue_isReachableByAuthenticatedUser() throws Exception {
|
||||
// The read GET must stay reachable for READ_ALL users — guards against a
|
||||
// future refactor accidentally write-guarding the undated triage path (#668).
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("undated", "true"))
|
||||
@@ -104,41 +103,43 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_undatedTrue_isForwardedToServiceAsTrue() throws Exception {
|
||||
ArgumentCaptor<Boolean> undatedCaptor = ArgumentCaptor.forClass(Boolean.class);
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("undated", "true"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), undatedCaptor.capture(), any());
|
||||
assertThat(undatedCaptor.getValue()).isTrue();
|
||||
verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
|
||||
assertThat(filtersCaptor.getValue().undated()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_withoutUndatedParam_forwardsFalseToService() throws Exception {
|
||||
ArgumentCaptor<Boolean> undatedCaptor = ArgumentCaptor.forClass(Boolean.class);
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
ArgumentCaptor<SearchFilters> filtersCaptor = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), undatedCaptor.capture(), any());
|
||||
assertThat(undatedCaptor.getValue()).isFalse();
|
||||
verify(documentService).searchDocuments(filtersCaptor.capture(), any(), any(), any());
|
||||
assertThat(filtersCaptor.getValue().undated()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
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()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||
.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
|
||||
@@ -165,7 +166,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
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()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
@@ -180,7 +181,7 @@ class DocumentControllerTest {
|
||||
UUID docId = UUID.randomUUID();
|
||||
var matchData = new SearchMatchData(
|
||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||
docId, "Brief an Anna", "brief.pdf", null, null,
|
||||
DatePrecision.UNKNOWN, null, null,
|
||||
@@ -200,7 +201,7 @@ class DocumentControllerTest {
|
||||
void search_returns_flat_item_with_id_and_without_sensitive_fields() throws Exception {
|
||||
UUID docId = UUID.randomUUID();
|
||||
var matchData = new SearchMatchData(null, List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any()))
|
||||
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||
docId, "Brief an Anna", "brief.pdf", null, null,
|
||||
DatePrecision.UNKNOWN, null, null,
|
||||
@@ -223,7 +224,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
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()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search"))
|
||||
@@ -268,7 +269,7 @@ class DocumentControllerTest {
|
||||
@Test
|
||||
@WithMockUser
|
||||
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()));
|
||||
|
||||
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
|
||||
@@ -276,7 +277,7 @@ class DocumentControllerTest {
|
||||
|
||||
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
|
||||
org.mockito.ArgumentCaptor.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.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
|
||||
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
|
||||
@@ -297,6 +298,13 @@ class DocumentControllerTest {
|
||||
.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
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createDocument_returns200_whenHasWritePermission() throws Exception {
|
||||
@@ -394,6 +402,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + id).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
@@ -414,6 +423,13 @@ class DocumentControllerTest {
|
||||
.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
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_returns200_withValidPdfFile() throws Exception {
|
||||
@@ -1194,7 +1210,7 @@ class DocumentControllerTest {
|
||||
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean()))
|
||||
when(documentService.findIdsForFilter(any()))
|
||||
.thenReturn(List.of(id));
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
@@ -1207,13 +1223,33 @@ class DocumentControllerTest {
|
||||
void getDocumentIds_passesSenderIdParamToService() throws Exception {
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
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());
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
|
||||
.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
|
||||
@@ -1223,7 +1259,7 @@ class DocumentControllerTest {
|
||||
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
|
||||
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
|
||||
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
|
||||
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean()))
|
||||
when(documentService.findIdsForFilter(any()))
|
||||
.thenReturn(tooMany);
|
||||
|
||||
mockMvc.perform(get("/api/documents/ids"))
|
||||
@@ -1388,16 +1424,16 @@ class DocumentControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void density_emitsPrivateCacheControlHeader() throws Exception {
|
||||
void density_isNeverBrowserCached() throws Exception {
|
||||
when(documentService.getDensity(any())).thenReturn(
|
||||
new DocumentDensityResult(List.of(), null, null));
|
||||
|
||||
// The endpoint sets no explicit Cache-Control, so Spring Security's
|
||||
// default no-store directive applies — the density chart is always fresh.
|
||||
mockMvc.perform(get("/api/documents/density"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("max-age=300")))
|
||||
.andExpect(header().string("Cache-Control",
|
||||
org.hamcrest.Matchers.containsString("private")));
|
||||
"no-cache, no-store, max-age=0, must-revalidate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -122,14 +123,36 @@ class DocumentLazyLoadingTest {
|
||||
savedDocument("SrDoc", "sr_doc.pdf", sender, Set.of(receiver), Set.of(tag));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.RECEIVER, "asc", null, false, PageRequest.of(0, 20));
|
||||
noFilters(),
|
||||
DocumentSort.RECEIVER, "asc", PageRequest.of(0, 20));
|
||||
assertThat(result.totalElements()).isGreaterThan(0);
|
||||
assertThatCode(() ->
|
||||
result.items().forEach(i -> { if (i.sender() != null) i.sender().getLastName(); }))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() {
|
||||
// q + default sort + no other filters → the relevance fast path
|
||||
// (relevanceSortedPageFromSql), which loads documents by id outside any
|
||||
// transaction and must still deliver an initialized tags collection.
|
||||
Person sender = savedPerson("Hans", "FtSender");
|
||||
Tag tag = savedTag("FtTag");
|
||||
savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag));
|
||||
|
||||
SearchFilters textOnly = new SearchFilters(
|
||||
"Walter", null, null, null, null, null, null, null, null, false);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
textOnly, null, "DESC", PageRequest.of(0, 10));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(1);
|
||||
assertThatCode(() ->
|
||||
result.items().forEach(i -> i.tags().size()))
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
|
||||
Person sender = savedPerson("Hans", "SsSender");
|
||||
@@ -137,8 +160,8 @@ class DocumentLazyLoadingTest {
|
||||
savedDocument("SsDoc", "ss_doc.pdf", sender, Set.of(), Set.of(tag));
|
||||
|
||||
assertThatCode(() -> documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.SENDER, "asc", null, false, PageRequest.of(0, 20)))
|
||||
noFilters(),
|
||||
DocumentSort.SENDER, "asc", PageRequest.of(0, 20)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
/**
|
||||
@@ -55,8 +56,8 @@ class DocumentListItemIntegrationTest {
|
||||
.build());
|
||||
|
||||
assertThatCode(() -> documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50)))
|
||||
noFilters(),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(0, 50)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@@ -70,8 +71,8 @@ class DocumentListItemIntegrationTest {
|
||||
.build());
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
noFilters(),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.totalElements()).isGreaterThan(0);
|
||||
DocumentListItem item = result.items().get(0);
|
||||
@@ -91,8 +92,8 @@ class DocumentListItemIntegrationTest {
|
||||
.build());
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
noFilters(),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||
|
||||
DocumentListItem item = result.items().stream()
|
||||
.filter(i -> i.title().equals("Range Brief")).findFirst().orElseThrow();
|
||||
|
||||
@@ -38,7 +38,10 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@@ -259,67 +262,6 @@ class DocumentRepositoryTest {
|
||||
assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
|
||||
}
|
||||
|
||||
// ─── findSinglePersonCorrespondence — DISTINCT / multi-receiver safety ────
|
||||
|
||||
@Test
|
||||
void findSinglePersonCorrespondence_returnsExactlyOneResult_whenDocumentHasThreeReceiversAndOneMatchesPersonId() {
|
||||
Person sender = personRepository.save(Person.builder()
|
||||
.firstName("Hans").lastName("Müller").build());
|
||||
Person receiver1 = personRepository.save(Person.builder()
|
||||
.firstName("Anna").lastName("Schmidt").build());
|
||||
Person receiver2 = personRepository.save(Person.builder()
|
||||
.firstName("Bertha").lastName("Wagner").build());
|
||||
Person receiver3 = personRepository.save(Person.builder()
|
||||
.firstName("Clara").lastName("Koch").build());
|
||||
|
||||
// Document addressed to all three receivers
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Rundschreiben")
|
||||
.originalFilename("rundschreiben.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender)
|
||||
.receivers(new HashSet<>(Set.of(receiver1, receiver2, receiver3)))
|
||||
.documentDate(LocalDate.of(1950, 6, 1))
|
||||
.build());
|
||||
|
||||
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||
LocalDate to = LocalDate.of(2000, 1, 1);
|
||||
|
||||
// Query for receiver1 — the DISTINCT must collapse the 3 JOIN rows into 1 result
|
||||
List<Document> results = documentRepository.findSinglePersonCorrespondence(
|
||||
receiver1.getId(), from, to, sort);
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getId()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSinglePersonCorrespondence_includesDocumentsWherePerson_isSender() {
|
||||
Person sender = personRepository.save(Person.builder()
|
||||
.firstName("Hans").lastName("Müller").build());
|
||||
Person receiver = personRepository.save(Person.builder()
|
||||
.firstName("Anna").lastName("Schmidt").build());
|
||||
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Brief als Absender")
|
||||
.originalFilename("brief_absender.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender)
|
||||
.receivers(new HashSet<>(Set.of(receiver)))
|
||||
.documentDate(LocalDate.of(1950, 6, 1))
|
||||
.build());
|
||||
|
||||
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||
LocalDate to = LocalDate.of(2000, 1, 1);
|
||||
|
||||
List<Document> results = documentRepository.findSinglePersonCorrespondence(
|
||||
sender.getId(), from, to, sort);
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
}
|
||||
|
||||
// ─── findSegmentationQueue ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -612,6 +554,48 @@ class DocumentRepositoryTest {
|
||||
.isLessThanOrEqualTo(5);
|
||||
}
|
||||
|
||||
// ─── V69 date-range CHECK constraints (#678) ──────────────────────────────
|
||||
|
||||
@Test
|
||||
void save_acceptsRange_whenEndEqualsStart() {
|
||||
// chk_meta_date_end_after_start is end >= start, so equal dates are valid.
|
||||
// Real Postgres + Flyway here (H2 would not enforce the CHECK) pins the
|
||||
// app guard's isBefore semantics to the actual constraint — guards drift (AC2).
|
||||
LocalDate day = LocalDate.of(1917, 1, 10);
|
||||
Document saved = documentRepository.saveAndFlush(Document.builder()
|
||||
.title("Gleicher Tag")
|
||||
.originalFilename("gleicher_tag.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.documentDate(day)
|
||||
.metaDatePrecision(DatePrecision.RANGE)
|
||||
.metaDateEnd(day)
|
||||
.build());
|
||||
|
||||
Document found = documentRepository.findById(saved.getId()).orElseThrow();
|
||||
assertThat(found.getDocumentDate()).isEqualTo(day);
|
||||
assertThat(found.getMetaDateEnd()).isEqualTo(day);
|
||||
assertThat(found.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_rejectsRange_whenEndBeforeStart_atDbLevel() {
|
||||
// The app guard normally intercepts this, so the DB CHECK never fires in practice.
|
||||
// Persisting directly proves chk_meta_date_end_after_start actually rejects end < start
|
||||
// (H2 would not) — if the app guard ever regresses, a bad row still can't reach the table,
|
||||
// and this is exactly the violation the GlobalExceptionHandler backstop turns into a 400.
|
||||
Document doc = Document.builder()
|
||||
.title("Verdrehte Spanne")
|
||||
.originalFilename("verdreht.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.documentDate(LocalDate.of(1917, 1, 11))
|
||||
.metaDatePrecision(DatePrecision.RANGE)
|
||||
.metaDateEnd(LocalDate.of(1917, 1, 10))
|
||||
.build();
|
||||
|
||||
assertThatThrownBy(() -> documentRepository.saveAndFlush(doc))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
// ─── seeding helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private Document uploaded(String title) {
|
||||
@@ -640,4 +624,88 @@ class DocumentRepositoryTest {
|
||||
.reviewed(reviewed)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ─── searchDocumentsByPersonId (via Specification) ───────────────────────
|
||||
|
||||
private Page<Document> searchByPerson(Person person, LocalDate from, LocalDate to) {
|
||||
Specification<Document> spec = (root, query, cb) -> {
|
||||
if (query != null) query.distinct(true);
|
||||
var receiversJoin = root.join("receivers", jakarta.persistence.criteria.JoinType.LEFT);
|
||||
var personPredicate = cb.or(
|
||||
cb.equal(root.get("sender"), person),
|
||||
cb.equal(receiversJoin, person));
|
||||
var predicates = new java.util.ArrayList<>(java.util.List.of(personPredicate));
|
||||
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
|
||||
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
|
||||
return cb.and(predicates.toArray(new jakarta.persistence.criteria.Predicate[0]));
|
||||
};
|
||||
return documentRepository.findAll(spec, PageRequest.of(0, 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByPersonSpec_returnsDocument_whenPersonIsSender() {
|
||||
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Senderbrief").originalFilename("sender.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(person).build());
|
||||
|
||||
Page<Document> result = searchByPerson(person, null, null);
|
||||
|
||||
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByPersonSpec_returnsDocument_whenPersonIsReceiver() {
|
||||
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Empfängerbrief").originalFilename("receiver.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.receivers(new java.util.HashSet<>(List.of(person))).build());
|
||||
|
||||
Page<Document> result = searchByPerson(person, null, null);
|
||||
|
||||
assertThat(result.getContent()).extracting(Document::getId).containsExactly(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByPersonSpec_returnsDocumentOnce_whenPersonIsBothSenderAndReceiver() {
|
||||
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("SenderEmpfänger").originalFilename("both.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(person)
|
||||
.receivers(new java.util.HashSet<>(List.of(person))).build());
|
||||
|
||||
Page<Document> result = searchByPerson(person, null, null);
|
||||
|
||||
assertThat(result.getContent()).hasSize(1);
|
||||
assertThat(result.getContent().get(0).getId()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByPersonSpec_excludesDocuments_outsideDateRange() {
|
||||
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||
Document inside = documentRepository.save(Document.builder()
|
||||
.title("Innen").originalFilename("inside.pdf").status(DocumentStatus.UPLOADED)
|
||||
.sender(person).documentDate(LocalDate.of(1918, 6, 15)).build());
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Außen").originalFilename("outside.pdf").status(DocumentStatus.UPLOADED)
|
||||
.sender(person).documentDate(LocalDate.of(1920, 1, 1)).build());
|
||||
|
||||
Page<Document> result = searchByPerson(person, LocalDate.of(1914, 1, 1), LocalDate.of(1918, 12, 31));
|
||||
|
||||
assertThat(result.getContent()).extracting(Document::getId).containsExactly(inside.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByPersonSpec_returnsEmpty_whenNoMatchingDocuments() {
|
||||
Person person = personRepository.save(Person.builder().lastName("Raddatz").build());
|
||||
Person other = personRepository.save(Person.builder().lastName("Braun").build());
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Fremder Brief").originalFilename("other.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(other).build());
|
||||
|
||||
Page<Document> result = searchByPerson(person, null, null);
|
||||
|
||||
assertThat(result.getContent()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
|
||||
|
||||
/**
|
||||
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
|
||||
@@ -61,8 +62,8 @@ class DocumentSearchPagedIntegrationTest {
|
||||
@Test
|
||||
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
noFilters(),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.items()).hasSize(50);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
@@ -74,8 +75,8 @@ class DocumentSearchPagedIntegrationTest {
|
||||
@Test
|
||||
void search_lastPartialPage_returnsRemainingItems() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(2, 50));
|
||||
noFilters(),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(2, 50));
|
||||
|
||||
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
|
||||
assertThat(result.items()).hasSize(20);
|
||||
@@ -86,8 +87,8 @@ class DocumentSearchPagedIntegrationTest {
|
||||
@Test
|
||||
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(99, 50));
|
||||
noFilters(),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(99, 50));
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
@@ -99,8 +100,8 @@ class DocumentSearchPagedIntegrationTest {
|
||||
// comment in DocumentService). Proves that the in-memory slice path
|
||||
// returns the correct total from a real repository fetch.
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.SENDER, "asc", null, false, PageRequest.of(1, 50));
|
||||
noFilters(),
|
||||
DocumentSort.SENDER, "asc", PageRequest.of(1, 50));
|
||||
|
||||
assertThat(result.items()).hasSize(50);
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
|
||||
@@ -125,8 +126,8 @@ class DocumentSearchPagedIntegrationTest {
|
||||
}
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
noFilters(),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||
|
||||
// Global undated count is the full undated total, independent of page size.
|
||||
assertThat(result.undatedCount()).isEqualTo(undatedTotal);
|
||||
@@ -153,11 +154,11 @@ class DocumentSearchPagedIntegrationTest {
|
||||
}
|
||||
|
||||
DocumentSearchResult unfiltered = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
noFilters(),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||
DocumentSearchResult undatedOnly = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, true, PageRequest.of(0, 50));
|
||||
noFilters().withUndated(true),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||
|
||||
assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
|
||||
assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
|
||||
@@ -178,9 +179,9 @@ class DocumentSearchPagedIntegrationTest {
|
||||
}
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
|
||||
null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
new SearchFilters(null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
|
||||
null, null, null, null, null, null, false),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.undatedCount()).isZero();
|
||||
}
|
||||
@@ -188,11 +189,11 @@ class DocumentSearchPagedIntegrationTest {
|
||||
@Test
|
||||
void search_differentPagesReturnDisjointSlices() {
|
||||
DocumentSearchResult page0 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
noFilters(),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(0, 50));
|
||||
DocumentSearchResult page1 = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(1, 50));
|
||||
noFilters(),
|
||||
DocumentSort.DATE, "DESC", PageRequest.of(1, 50));
|
||||
|
||||
// No document id should appear on both pages — slicing must be exclusive.
|
||||
var idsOnPage0 = page0.items().stream()
|
||||
|
||||
@@ -67,7 +67,8 @@ class DocumentServiceSortTest {
|
||||
.thenReturn(new PageImpl<>(List.of(newer, older)));
|
||||
|
||||
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().get(0).id()).isEqualTo(id2); // newer first
|
||||
@@ -80,11 +81,12 @@ class DocumentServiceSortTest {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any()))
|
||||
when(documentRepository.findByIdIn(any()))
|
||||
.thenReturn(List.of(doc(id1)));
|
||||
|
||||
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, never()).findAllMatchingIdsByFts(anyString());
|
||||
@@ -99,10 +101,11 @@ class DocumentServiceSortTest {
|
||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"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);
|
||||
}
|
||||
@@ -116,10 +119,11 @@ class DocumentServiceSortTest {
|
||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -132,8 +136,8 @@ class DocumentServiceSortTest {
|
||||
Pageable hugePage = org.springframework.data.domain.PageRequest.of(Integer.MAX_VALUE / 10 + 1, 10);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
"Brief", null, null, null, null, null, null, null,
|
||||
DocumentSort.RELEVANCE, null, null, false, hugePage);
|
||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||
DocumentSort.RELEVANCE, null, hugePage);
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
verify(documentRepository, never()).findFtsPageRaw(anyString(), anyInt(), anyInt());
|
||||
@@ -149,11 +153,11 @@ class DocumentServiceSortTest {
|
||||
List<Object[]> ftsRows = new ArrayList<>();
|
||||
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId)));
|
||||
|
||||
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()).hasSize(1);
|
||||
assertThat(result.items().get(0).id()).isEqualTo(uuidId);
|
||||
@@ -173,7 +177,8 @@ class DocumentServiceSortTest {
|
||||
// sender filter is active → triggers in-memory path, not findFtsPageRaw
|
||||
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||
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).findAllMatchingIdsByFts("Brief");
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||
@@ -20,6 +21,7 @@ import org.raddatz.familienarchiv.document.MatchOffset;
|
||||
import org.raddatz.familienarchiv.document.SearchMatchData;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
@@ -28,6 +30,7 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.filestorage.FileService;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
@@ -45,6 +48,7 @@ import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.raddatz.familienarchiv.document.SearchFiltersFixtures.noFilters;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
@@ -72,6 +76,10 @@ class DocumentServiceTest {
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
@Mock ApplicationEventPublisher eventPublisher;
|
||||
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
|
||||
// shared composition rather than a stub — the #726 single source of truth.
|
||||
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
|
||||
@InjectMocks DocumentService documentService;
|
||||
|
||||
// ─── deleteDocument ───────────────────────────────────────────────────────
|
||||
@@ -81,7 +89,7 @@ class DocumentServiceTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.existsById(id)).thenReturn(true);
|
||||
|
||||
documentService.deleteDocument(id);
|
||||
documentService.deleteDocument(id, UUID.randomUUID());
|
||||
|
||||
verify(documentRepository).deleteById(id);
|
||||
}
|
||||
@@ -91,7 +99,7 @@ class DocumentServiceTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.existsById(id)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> documentService.deleteDocument(id))
|
||||
assertThatThrownBy(() -> documentService.deleteDocument(id, UUID.randomUUID()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(id.toString());
|
||||
verify(documentRepository, never()).deleteById(any());
|
||||
@@ -118,6 +126,37 @@ class DocumentServiceTest {
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -171,10 +210,12 @@ class DocumentServiceTest {
|
||||
// Editing a doc (e.g. fixing a location typo) without touching the precision
|
||||
// controls must NOT fabricate a precision. The form omits the three precision
|
||||
// fields → they arrive null on the DTO → the stored values must be preserved.
|
||||
// Stored combo is RANGE + end: the only DB-valid way to have a non-null end
|
||||
// (chk_meta_date_end_only_for_range), so the carried-over state passes the guard.
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.metaDatePrecision(DatePrecision.MONTH)
|
||||
.metaDatePrecision(DatePrecision.RANGE)
|
||||
.metaDateEnd(LocalDate.of(1916, 6, 30))
|
||||
.metaDateRaw("Juni 1916")
|
||||
.receivers(new HashSet<>())
|
||||
@@ -188,11 +229,329 @@ class DocumentServiceTest {
|
||||
|
||||
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.getMetaDateRaw()).isEqualTo("Juni 1916");
|
||||
}
|
||||
|
||||
// ─── updateDocument save-time auto-title regeneration (#726) ──────────────
|
||||
//
|
||||
// Exact old-vs-new comparison: the title is the catalog auto-title iff the submitted
|
||||
// title equals what the factory builds from the CURRENTLY-persisted state. The edit form
|
||||
// round-trips the stored title verbatim when untouched, so an equal submission means the
|
||||
// user did not type over it. makeStored() seeds index/date/precision/location and sets the
|
||||
// stored title to the matching auto-title, mirroring a freshly-imported row.
|
||||
|
||||
private Document makeStored(String index, LocalDate date, DatePrecision precision, String location) {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.originalFilename(index)
|
||||
.documentDate(date)
|
||||
.metaDatePrecision(precision)
|
||||
.location(location)
|
||||
.receivers(new HashSet<>())
|
||||
.tags(new HashSet<>())
|
||||
.build();
|
||||
doc.setTitle(documentTitleFactory.build(doc));
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** A DTO that round-trips the stored auto-title untouched, with new date/precision/location. */
|
||||
private static DocumentUpdateDTO editDto(String submittedTitle, LocalDate date,
|
||||
DatePrecision precision, String location) {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle(submittedTitle);
|
||||
dto.setDocumentDate(date);
|
||||
dto.setMetaDatePrecision(precision);
|
||||
dto.setLocation(location);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private Document runUpdate(Document stored, DocumentUpdateDTO dto) throws Exception {
|
||||
when(documentRepository.findById(stored.getId())).thenReturn(Optional.of(stored));
|
||||
when(documentRepository.save(any())).thenReturn(stored);
|
||||
documentService.updateDocument(stored.getId(), dto, null, null);
|
||||
return stored;
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratesAutoTitle_whenDateChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// title untouched ("C-0029 – 2028 – Berlin"), date corrected to 1928
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_keepsHandWrittenTitle_whenDateChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
stored.setTitle("C-0029 – Brief an Mutter"); // hand-written, ≠ auto-title
|
||||
DocumentUpdateDTO dto = editDto("C-0029 – Brief an Mutter", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Brief an Mutter");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_freshlyTypedTitleWins_overRegeneration() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// user changed the date AND typed a new title in the same save
|
||||
DocumentUpdateDTO dto = editDto("Geburtsanzeige", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("Geburtsanzeige");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratesWithNewDateAndLocation() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "München");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – München");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_dropsTrailingLocationSegment_whenLocationCleared() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
// location cleared (null), title untouched
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_regeneratedTitle_doesNotContainOldDate() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(2028, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).doesNotContain("2028");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_relabelsOnPrecisionChange_yearToDay() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
// stored auto-title "C-0029 – 1928"; set a full day at DAY precision
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 15), DatePrecision.DAY, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 15. Januar 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_populatesTitle_whenDateAddedToUnknownRow() throws Exception {
|
||||
Document stored = makeStored("C-0029", null, DatePrecision.UNKNOWN, null);
|
||||
// stored auto-title is just "C-0029"; add a 1928 YEAR date
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_roundTripsSeasonLabel() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||
stored.setMetaDateRaw("Frühling 1943");
|
||||
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – Frühling 1943"
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||
dto.setMetaDateRaw("Frühling 1943");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1943");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_carriesStoredPrecisionAndRaw_whenDtoOmitsThem() throws Exception {
|
||||
// Only the year changes; precision/end/raw are omitted from the DTO, so projectedState
|
||||
// must carry them from the entity (exercises the skip-null effective* resolvers).
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1943, 4, 1), DatePrecision.SEASON, null);
|
||||
stored.setMetaDateRaw("Frühling 1943");
|
||||
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – Frühling 1943"
|
||||
DocumentUpdateDTO dto = editDto(stored.getTitle(), LocalDate.of(1944, 4, 1), null, null);
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – Frühling 1944");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_roundTripsRangeLabel_atSaveTime() throws Exception {
|
||||
Document stored = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.originalFilename("C-0029")
|
||||
.documentDate(LocalDate.of(1917, 1, 10))
|
||||
.metaDatePrecision(DatePrecision.RANGE)
|
||||
.metaDateEnd(LocalDate.of(1917, 1, 11))
|
||||
.receivers(new HashSet<>())
|
||||
.tags(new HashSet<>())
|
||||
.build();
|
||||
stored.setTitle(documentTitleFactory.build(stored)); // "C-0029 – 10.–11. Jan. 1917"
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle(stored.getTitle());
|
||||
dto.setDocumentDate(LocalDate.of(1918, 1, 10));
|
||||
dto.setMetaDatePrecision(DatePrecision.RANGE);
|
||||
dto.setMetaDateEnd(LocalDate.of(1918, 1, 11));
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 10.–11. Jan. 1918");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_doesNotRegenerateToBlank_whenSubmittedTitleEmpty() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
DocumentUpdateDTO dto = editDto("", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_treatsFileReplacedDoc_asManual() throws Exception {
|
||||
// originalFilename was reassigned by an earlier file-replace, so the stored title (built
|
||||
// at import from the old index) no longer matches build(currentState) → treated as manual.
|
||||
Document stored = makeStored("scan_2024.pdf", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
stored.setTitle("C-0029 – 1928 – Berlin"); // legacy import title, ≠ build("scan_2024.pdf"…)
|
||||
DocumentUpdateDTO dto = editDto("C-0029 – 1928 – Berlin", LocalDate.of(1930, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_idempotent_whenNothingChanges() throws Exception {
|
||||
Document stored = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
String before = stored.getTitle();
|
||||
DocumentUpdateDTO dto = editDto(before, LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
|
||||
runUpdate(stored, dto);
|
||||
|
||||
assertThat(stored.getTitle()).isEqualTo(before);
|
||||
}
|
||||
|
||||
// ─── updateDocument date-range validation (#678) ──────────────────────────
|
||||
|
||||
/** Builds a stored doc ready for an updateDocument call (collections initialised). */
|
||||
private static Document docForRangeUpdate(UUID id) {
|
||||
return Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
|
||||
}
|
||||
|
||||
private static DocumentUpdateDTO rangeDto(LocalDate start, LocalDate end) {
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setDocumentDate(start);
|
||||
dto.setMetaDatePrecision(DatePrecision.RANGE);
|
||||
dto.setMetaDateEnd(end);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_rejectsRange_whenEndBeforeStart() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = docForRangeUpdate(id);
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
|
||||
DocumentUpdateDTO dto = rangeDto(LocalDate.of(1917, 1, 11), LocalDate.of(1917, 1, 10));
|
||||
|
||||
assertThatThrownBy(() -> documentService.updateDocument(id, dto, null, null))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INVALID_DATE_RANGE);
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_acceptsRange_whenEndEqualsStart() throws Exception {
|
||||
// AC2: the DB CHECK is end >= start, so equal dates are valid.
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = docForRangeUpdate(id);
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenReturn(doc);
|
||||
|
||||
LocalDate same = LocalDate.of(1917, 1, 10);
|
||||
documentService.updateDocument(id, rangeDto(same, same), null, null);
|
||||
|
||||
assertThat(doc.getMetaDateEnd()).isEqualTo(same);
|
||||
verify(documentRepository, atLeastOnce()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_acceptsRange_whenEndAfterStart() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = docForRangeUpdate(id);
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenReturn(doc);
|
||||
|
||||
documentService.updateDocument(id,
|
||||
rangeDto(LocalDate.of(1917, 1, 10), LocalDate.of(1917, 1, 11)), null, null);
|
||||
|
||||
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11));
|
||||
verify(documentRepository, atLeastOnce()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_acceptsRange_whenEndIsNull_openEnded() throws Exception {
|
||||
// AC3: an open-ended range (no end) is valid.
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = docForRangeUpdate(id);
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenReturn(doc);
|
||||
|
||||
documentService.updateDocument(id,
|
||||
rangeDto(LocalDate.of(1917, 1, 10), null), null, null);
|
||||
|
||||
verify(documentRepository, atLeastOnce()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_acceptsRange_whenStartNullAndEndSet() throws Exception {
|
||||
// AC4: mirrors the DB "meta_date IS NULL" escape — must NOT reject (and must not NPE).
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = docForRangeUpdate(id);
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
when(documentRepository.save(any())).thenReturn(doc);
|
||||
|
||||
documentService.updateDocument(id,
|
||||
rangeDto(null, LocalDate.of(1917, 1, 11)), null, null);
|
||||
|
||||
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11));
|
||||
verify(documentRepository, atLeastOnce()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_rejectsEndDate_whenPrecisionNotRange() {
|
||||
// AC6: an end date only makes sense for RANGE (mirrors chk_meta_date_end_only_for_range).
|
||||
// API-only — the edit form clears the end field off-RANGE — so close the 500 class here too.
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = docForRangeUpdate(id);
|
||||
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setDocumentDate(LocalDate.of(1917, 1, 10));
|
||||
dto.setMetaDatePrecision(DatePrecision.MONTH);
|
||||
dto.setMetaDateEnd(LocalDate.of(1917, 1, 31));
|
||||
|
||||
assertThatThrownBy(() -> documentService.updateDocument(id, dto, null, null))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INVALID_DATE_RANGE);
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -338,6 +697,59 @@ class DocumentServiceTest {
|
||||
verify(documentVersionService).recordVersion(any(Document.class));
|
||||
}
|
||||
|
||||
// ─── backfillTitles — one-time stale-title cleanup (#726, FR-003) ─────────
|
||||
|
||||
@Test
|
||||
void backfillTitles_rewritesStaleAutoTitle_andCountsIt() {
|
||||
Document stale = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
stale.setTitle("C-0029 – 2028 – Berlin"); // stale stored title (date typo never fixed)
|
||||
when(documentRepository.findAll()).thenReturn(List.of(stale));
|
||||
when(documentRepository.save(any())).thenReturn(stale);
|
||||
|
||||
int count = documentService.backfillTitles();
|
||||
|
||||
assertThat(count).isEqualTo(1);
|
||||
assertThat(stale.getTitle()).isEqualTo("C-0029 – 1928 – Berlin");
|
||||
verify(documentRepository).save(stale);
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillTitles_skipsProse() {
|
||||
Document prose = makeStored("C-0030", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, null);
|
||||
prose.setTitle("C-0030 – Brief an Mutter");
|
||||
when(documentRepository.findAll()).thenReturn(List.of(prose));
|
||||
|
||||
int count = documentService.backfillTitles();
|
||||
|
||||
assertThat(count).isZero();
|
||||
assertThat(prose.getTitle()).isEqualTo("C-0030 – Brief an Mutter");
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillTitles_isIdempotent_forAlreadyCorrectTitle() {
|
||||
Document fresh = makeStored("C-0031", LocalDate.of(1940, 1, 1), DatePrecision.YEAR, null);
|
||||
// title already equals build(current state) → nothing to do
|
||||
when(documentRepository.findAll()).thenReturn(List.of(fresh));
|
||||
|
||||
int count = documentService.backfillTitles();
|
||||
|
||||
assertThat(count).isZero();
|
||||
verify(documentRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfillTitles_neverRecordsVersions() {
|
||||
Document stale = makeStored("C-0029", LocalDate.of(1928, 1, 1), DatePrecision.YEAR, "Berlin");
|
||||
stale.setTitle("C-0029 – 2028 – Berlin");
|
||||
when(documentRepository.findAll()).thenReturn(List.of(stale));
|
||||
when(documentRepository.save(any())).thenReturn(stale);
|
||||
|
||||
documentService.backfillTitles();
|
||||
|
||||
verify(documentVersionService, never()).recordVersion(any());
|
||||
}
|
||||
|
||||
// ─── thumbnail dispatch ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -985,53 +1397,6 @@ class DocumentServiceTest {
|
||||
.isEqualTo("19650332_Mueller_Hans");
|
||||
}
|
||||
|
||||
// ─── getConversationFiltered ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getConversationFiltered_passesGivenDates_whenFromAndToAreProvided() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
UUID receiverId = UUID.randomUUID();
|
||||
LocalDate from = LocalDate.of(1940, 1, 1);
|
||||
LocalDate to = LocalDate.of(1960, 12, 31);
|
||||
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
|
||||
when(documentRepository.findConversation(senderId, receiverId, from, to, sort))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
|
||||
|
||||
verify(documentRepository).findConversation(senderId, receiverId, from, to, sort);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConversationFiltered_usesMinDateForFrom_whenFromIsNull() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
UUID receiverId = UUID.randomUUID();
|
||||
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
|
||||
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
|
||||
|
||||
ArgumentCaptor<LocalDate> fromCaptor = ArgumentCaptor.forClass(LocalDate.class);
|
||||
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), fromCaptor.capture(), any(LocalDate.class), eq(sort));
|
||||
assertThat(fromCaptor.getValue()).isEqualTo(LocalDate.parse("0000-01-01"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConversationFiltered_usesTodayForTo_whenToIsNull() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
UUID receiverId = UUID.randomUUID();
|
||||
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
|
||||
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
|
||||
|
||||
ArgumentCaptor<LocalDate> toCaptor = ArgumentCaptor.forClass(LocalDate.class);
|
||||
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), toCaptor.capture(), eq(sort));
|
||||
assertThat(toCaptor.getValue()).isEqualTo(LocalDate.now());
|
||||
}
|
||||
|
||||
// ─── updateDocumentTags — empty tag in list ───────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -1410,8 +1775,9 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
documentService.searchDocuments(
|
||||
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, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
@@ -1423,8 +1789,9 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(3, 25));
|
||||
documentService.searchDocuments(
|
||||
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());
|
||||
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
|
||||
@@ -1439,8 +1806,9 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.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,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 50));
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
noFilters(),
|
||||
org.raddatz.familienarchiv.document.DocumentSort.DATE, "DESC", org.springframework.data.domain.PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isZero();
|
||||
@@ -1455,8 +1823,9 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
|
||||
documentService.searchDocuments(
|
||||
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());
|
||||
Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate");
|
||||
@@ -1478,8 +1847,9 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "ASC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
|
||||
documentService.searchDocuments(
|
||||
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());
|
||||
Sort.Order dateOrder = captor.getValue().getSort().getOrderFor("documentDate");
|
||||
@@ -1499,8 +1869,9 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
DocumentSort.UPDATED_AT, "DESC", null, false, org.springframework.data.domain.PageRequest.of(0, 5));
|
||||
documentService.searchDocuments(
|
||||
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());
|
||||
assertThat(captor.getValue().getSort())
|
||||
@@ -1523,8 +1894,9 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
noFilters(),
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", org.springframework.data.domain.PageRequest.of(1, 50));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(120L);
|
||||
assertThat(result.pageNumber()).isEqualTo(1);
|
||||
@@ -1547,8 +1919,9 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(all);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", null, false, org.springframework.data.domain.PageRequest.of(10, 50));
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
noFilters(),
|
||||
org.raddatz.familienarchiv.document.DocumentSort.SENDER, "asc", org.springframework.data.domain.PageRequest.of(10, 50));
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
assertThat(result.totalElements()).isEqualTo(30L);
|
||||
@@ -1561,7 +1934,8 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.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));
|
||||
}
|
||||
@@ -1571,7 +1945,8 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
|
||||
.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));
|
||||
}
|
||||
@@ -1607,35 +1982,6 @@ class DocumentServiceTest {
|
||||
.isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
}
|
||||
|
||||
// ─── getConversationFiltered (single-person mode) ─────────────────────────
|
||||
|
||||
@Test
|
||||
void getConversationFiltered_callsSinglePersonQuery_whenReceiverIdIsNull() {
|
||||
UUID personId = UUID.randomUUID();
|
||||
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||
when(documentRepository.findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.getConversationFiltered(personId, null, null, null, sort);
|
||||
|
||||
verify(documentRepository).findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort));
|
||||
verify(documentRepository, never()).findConversation(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getConversationFiltered_callsBilateralQuery_whenReceiverIdIsSet() {
|
||||
UUID senderId = UUID.randomUUID();
|
||||
UUID receiverId = UUID.randomUUID();
|
||||
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
|
||||
|
||||
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort));
|
||||
verify(documentRepository, never()).findSinglePersonCorrespondence(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── searchDocuments — SENDER sort includes documents with null sender ─────
|
||||
|
||||
@Test
|
||||
@@ -1649,7 +1995,8 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(withSender, noSender));
|
||||
|
||||
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()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
|
||||
@@ -1669,7 +2016,8 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(noReceivers, withReceiver));
|
||||
|
||||
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)
|
||||
.containsExactly("Has Receiver", "No Receivers");
|
||||
@@ -1702,7 +2050,8 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
|
||||
|
||||
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
|
||||
// within each group the input order is preserved (undatedBob, datedBob for Bob;
|
||||
@@ -1733,7 +2082,8 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna));
|
||||
|
||||
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.
|
||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||
@@ -1756,7 +2106,8 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(undatedFromAlice));
|
||||
|
||||
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
|
||||
// undatedOnly(true)) rather than skipping straight to a sorted findAll.
|
||||
@@ -1772,8 +2123,9 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
documentService.searchDocuments("brief", null, null, null, null, null, null, null,
|
||||
DocumentSort.RELEVANCE, null, null, true, UNPAGED);
|
||||
documentService.searchDocuments(
|
||||
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.
|
||||
verify(documentRepository).findAllMatchingIdsByFts("brief");
|
||||
@@ -1796,7 +2148,8 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(docNullName, docSmith));
|
||||
|
||||
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")
|
||||
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||
@@ -1815,11 +2168,12 @@ class DocumentServiceTest {
|
||||
List<Object[]> ftsRows = new java.util.ArrayList<>();
|
||||
ftsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
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);
|
||||
SearchMatchData md = result.items().get(0).matchData();
|
||||
@@ -1833,7 +2187,8 @@ class DocumentServiceTest {
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -1849,11 +2204,12 @@ class DocumentServiceTest {
|
||||
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
|
||||
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
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();
|
||||
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
|
||||
@@ -2370,7 +2726,7 @@ class DocumentServiceTest {
|
||||
.thenReturn(List.of(d1, d2));
|
||||
|
||||
List<UUID> result = documentService.findIdsForFilter(
|
||||
null, null, null, null, null, null, null, null, null, false);
|
||||
noFilters());
|
||||
|
||||
assertThat(result).containsExactly(d1.getId(), d2.getId());
|
||||
}
|
||||
@@ -2385,7 +2741,7 @@ class DocumentServiceTest {
|
||||
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
|
||||
|
||||
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
|
||||
// is in not-throwing on the OR-specific code path; the actual SQL is
|
||||
@@ -2398,7 +2754,7 @@ class DocumentServiceTest {
|
||||
when(documentRepository.findAllMatchingIdsByFts("xyz")).thenReturn(List.of());
|
||||
|
||||
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();
|
||||
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.ObjectMapper;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
/** Test fixtures for {@link SearchFilters}. */
|
||||
final class SearchFiltersFixtures {
|
||||
|
||||
private SearchFiltersFixtures() {}
|
||||
|
||||
/**
|
||||
* A {@link SearchFilters} with no predicate active — the common search-test
|
||||
* baseline. Combine with {@code .withUndated(true)} for the undated-only case;
|
||||
* construct {@code new SearchFilters(...)} directly when a test pins a specific
|
||||
* field, so the intent stays visible at the call site.
|
||||
*/
|
||||
static SearchFilters noFilters() {
|
||||
return new SearchFilters(null, null, null, null, null, null, null, null, null, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
/**
|
||||
* #730 — tag-name resolution against a real Postgres. A mocked repo can't prove the two things that
|
||||
* actually break: that {@code findAllByNameIgnoreCase} folds case the way Postgres {@code LOWER()}
|
||||
* does (critical for umlauts like {@code ü}), and that saving a document tagged with a case-colliding
|
||||
* tag no longer throws {@code NonUniqueResultException}. H2 folds case differently, so this pins the
|
||||
* behaviour on {@code postgres:16-alpine}. The four-branch resolution logic itself is covered faster
|
||||
* by the mocked {@code TagServiceTest}.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class TagCaseCollisionIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired TagRepository tagRepository;
|
||||
@Autowired TagService tagService;
|
||||
|
||||
private Tag persistTag(String name, String sourceRef, UUID parentId) {
|
||||
return tagRepository.save(Tag.builder().name(name).sourceRef(sourceRef).parentId(parentId).build());
|
||||
}
|
||||
|
||||
private Document persistDocTaggedWith(Tag tag) {
|
||||
return documentRepository.save(Document.builder()
|
||||
.originalFilename("C-7301")
|
||||
.title("Weihnachtsbrief")
|
||||
.documentDate(LocalDate.of(1928, 1, 1))
|
||||
.metaDatePrecision(DatePrecision.YEAR)
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.tags(new HashSet<>(Set.of(tag)))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_succeedsAndKeepsExactChildTag_whenTaggedWithCaseCollidingChild() throws Exception {
|
||||
Tag parent = persistTag("Weihnachten", "Weihnachten", null);
|
||||
Tag child = persistTag("weihnachten", "Weihnachten/weihnachten", parent.getId());
|
||||
Document doc = persistDocTaggedWith(child);
|
||||
|
||||
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||
dto.setTitle("Weihnachtsbrief");
|
||||
dto.setDocumentDate(LocalDate.of(1930, 1, 1)); // change the date — the field that 500'd on staging
|
||||
dto.setMetaDatePrecision(DatePrecision.YEAR);
|
||||
dto.setTags("weihnachten"); // the edit form round-trips the stored child name
|
||||
|
||||
assertThatCode(() -> documentService.updateDocument(doc.getId(), dto, null, null))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
Set<Tag> tags = documentRepository.findById(doc.getId()).orElseThrow().getTags();
|
||||
assertThat(tags).hasSize(1);
|
||||
assertThat(tags.iterator().next().getId()).isEqualTo(child.getId()); // child kept, not the parent
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreate_resolvesUmlautCollisionDeterministically_withoutThrow() {
|
||||
// The regression catcher: a plain-ASCII pair would stay green even if Postgres folded ü wrongly.
|
||||
Tag parent = persistTag("Glückwünsche", "Glückwünsche", null);
|
||||
Tag child = persistTag("glückwünsche", "Glückwünsche/glückwünsche", parent.getId());
|
||||
|
||||
// Proof that real Postgres LOWER() folds the umlaut so both rows match case-insensitively.
|
||||
// Query with the UPPERCASE form findOrCreate actually passes — folding LOWER('GLÜCKWÜNSCHE')
|
||||
// against LOWER(name) is the exact step under test; a lowercase probe wouldn't exercise it.
|
||||
assertThat(tagRepository.findAllByNameIgnoreCase("GLÜCKWÜNSCHE")).hasSize(2);
|
||||
|
||||
// No exact-case "GLÜCKWÜNSCHE" row exists → resolution falls through to the case-insensitive
|
||||
// branch with two candidates and must pick the lowest id deterministically, never throwing.
|
||||
UUID expected = List.of(parent, child).stream().min(Comparator.comparing(Tag::getId)).orElseThrow().getId();
|
||||
Tag first = tagService.findOrCreate("GLÜCKWÜNSCHE");
|
||||
Tag second = tagService.findOrCreate("GLÜCKWÜNSCHE");
|
||||
|
||||
assertThat(first.getId()).isEqualTo(expected);
|
||||
assertThat(second.getId()).isEqualTo(first.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkEdit_resolvesCaseCollidingTagThroughFindOrCreate_withoutThrow() {
|
||||
// Bulk-edit shares resolveTags → findOrCreate; this guards a future refactor that bypasses it.
|
||||
Tag parent = persistTag("Weihnachten", "Weihnachten", null);
|
||||
Tag child = persistTag("weihnachten", "Weihnachten/weihnachten", parent.getId());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.originalFilename("C-7302")
|
||||
.title("Brief")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
|
||||
DocumentBulkEditDTO dto = new DocumentBulkEditDTO();
|
||||
dto.setTagNames(List.of("weihnachten"));
|
||||
|
||||
assertThatCode(() -> documentService.applyBulkEditToDocument(doc.getId(), dto, null))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
Set<Tag> tags = documentRepository.findById(doc.getId()).orElseThrow().getTags();
|
||||
assertThat(tags).hasSize(1);
|
||||
assertThat(tags.iterator().next().getId()).isEqualTo(child.getId());
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,15 @@ class AnnotationControllerTest {
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@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
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception {
|
||||
@@ -190,6 +199,15 @@ class AnnotationControllerTest {
|
||||
.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
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void patchAnnotation_returns200_withWriteAllPermission() throws Exception {
|
||||
|
||||
@@ -94,6 +94,15 @@ class CommentControllerTest {
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void postBlockComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void postBlockComment_returns201_whenHasAnnotatePermission() throws Exception {
|
||||
@@ -142,6 +151,16 @@ class CommentControllerTest {
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void replyToBlockComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
UUID blockId = UUID.randomUUID();
|
||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
|
||||
@@ -181,6 +200,14 @@ class CommentControllerTest {
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void editComment_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||
void editComment_returns200_whenHasPermission() throws Exception {
|
||||
|
||||
@@ -159,6 +159,15 @@ class TranscriptionBlockControllerTest {
|
||||
.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
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void createBlock_returns201_withSavedBlock_whenAuthorised() throws Exception {
|
||||
@@ -233,6 +242,15 @@ class TranscriptionBlockControllerTest {
|
||||
.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
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updateBlock_returns200_withUpdatedBlock_whenAuthorised() throws Exception {
|
||||
@@ -363,6 +381,15 @@ class TranscriptionBlockControllerTest {
|
||||
.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
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
||||
@@ -440,6 +467,14 @@ class TranscriptionBlockControllerTest {
|
||||
.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 ───────────────────────────────────────────────────
|
||||
|
||||
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.transcription.PersonMention;
|
||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
@@ -30,6 +32,7 @@ class TranscriptionBlockMentionsRepositoryTest {
|
||||
@Autowired TranscriptionBlockRepository blockRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AnnotationRepository annotationRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired EntityManager em;
|
||||
|
||||
private UUID documentId;
|
||||
@@ -55,8 +58,9 @@ class TranscriptionBlockMentionsRepositoryTest {
|
||||
|
||||
@Test
|
||||
void mentionedPersons_roundTripsTwoEntries() {
|
||||
UUID auguste = UUID.randomUUID();
|
||||
UUID hermann = UUID.randomUUID();
|
||||
// person_id is a real FK since V71 — the mentioned persons must exist.
|
||||
UUID auguste = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId();
|
||||
UUID hermann = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId();
|
||||
|
||||
TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||
.annotationId(annotationId)
|
||||
@@ -97,8 +101,9 @@ class TranscriptionBlockMentionsRepositoryTest {
|
||||
|
||||
@Test
|
||||
void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() {
|
||||
UUID augusteId = UUID.randomUUID();
|
||||
UUID hermannId = UUID.randomUUID();
|
||||
// person_id is a real FK since V71 — the mentioned persons must exist.
|
||||
UUID augusteId = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId();
|
||||
UUID hermannId = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId();
|
||||
|
||||
blockRepository.saveAndFlush(TranscriptionBlock.builder()
|
||||
.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_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;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import io.sentry.Sentry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.dao.IncorrectResultSizeDataAccessException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -30,4 +37,108 @@ class GlobalExceptionHandlerTest {
|
||||
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleGeneric_incorrectResultSize_staysOpaque_noHibernateOrRowCountLeak() {
|
||||
// #731: before the fix, a case-colliding alias/name made Hibernate throw
|
||||
// NonUniqueResultException → IncorrectResultSizeDataAccessException, which has no
|
||||
// dedicated handler and falls through to handleGeneric. The fix removes the throw, but
|
||||
// this pins the handler: a stray one must stay opaque — no Hibernate class name, no SQL,
|
||||
// no "2 results were returned" row count reaching the client (CWE-209).
|
||||
IncorrectResultSizeDataAccessException ex = new IncorrectResultSizeDataAccessException(
|
||||
"query did not return a unique result: 2 results were returned", 1, 2);
|
||||
|
||||
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
|
||||
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response = handler.handleGeneric(ex);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(500);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR);
|
||||
assertThat(response.getBody().message())
|
||||
.isEqualTo("An unexpected error occurred")
|
||||
.doesNotContain("results were returned")
|
||||
.doesNotContain("NonUnique")
|
||||
.doesNotContain("IncorrectResultSize");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleDataIntegrityViolation_returns400_withoutLeakingConstraint_orSentry() {
|
||||
// A DataIntegrityViolationException carries the constraint name + SQL in its message;
|
||||
// the response and logs must never echo it (CWE-209). It must become a clean 400, not a 500.
|
||||
DataIntegrityViolationException ex = new DataIntegrityViolationException(
|
||||
"could not execute statement; constraint [chk_meta_date_end_after_start]; "
|
||||
+ "column meta_date_end of relation documents");
|
||||
|
||||
Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
handlerLogger.addAppender(appender);
|
||||
|
||||
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
|
||||
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response =
|
||||
handler.handleDataIntegrityViolation(ex);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(400);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().code()).isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
assertThat(response.getBody().message())
|
||||
.doesNotContain("chk_")
|
||||
.doesNotContain("meta_date");
|
||||
|
||||
// Defense-in-depth: an unanticipated integrity violation is not a system fault,
|
||||
// so it must NOT fabricate a Sentry alert.
|
||||
sentryMock.verifyNoInteractions();
|
||||
} finally {
|
||||
handlerLogger.detachAppender(appender);
|
||||
}
|
||||
|
||||
assertThat(appender.list)
|
||||
.as("logs a WARN line")
|
||||
.anySatisfy(e -> assertThat(e.getLevel()).isEqualTo(Level.WARN));
|
||||
assertThat(appender.list)
|
||||
.as("never logs the SQL statement / values (would re-leak to Loki)")
|
||||
.noneSatisfy(e -> {
|
||||
assertThat(e.getFormattedMessage()).contains("could not execute statement");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() {
|
||||
// Debuggability (DevOps): the WARN must name *which* constraint fired so an
|
||||
// unanticipated violation isn't a silent mystery — but it must carry the name only,
|
||||
// never the SQL statement or the offending values that the SQLException message holds.
|
||||
java.sql.SQLException sql = new java.sql.SQLException(
|
||||
"ERROR: violates check constraint; could not execute statement; values (1917-01-10)");
|
||||
org.hibernate.exception.ConstraintViolationException cve =
|
||||
new org.hibernate.exception.ConstraintViolationException(
|
||||
"constraint violation", sql, "chk_meta_date_end_after_start");
|
||||
DataIntegrityViolationException ex = new DataIntegrityViolationException("wrapper", cve);
|
||||
|
||||
Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
handlerLogger.addAppender(appender);
|
||||
|
||||
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
|
||||
ResponseEntity<GlobalExceptionHandler.ErrorResponse> response =
|
||||
handler.handleDataIntegrityViolation(ex);
|
||||
|
||||
// Response stays generic and leak-free (CWE-209) regardless of what we log.
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(400);
|
||||
assertThat(response.getBody().message())
|
||||
.doesNotContain("chk_")
|
||||
.doesNotContain("meta_date");
|
||||
sentryMock.verifyNoInteractions();
|
||||
} finally {
|
||||
handlerLogger.detachAppender(appender);
|
||||
}
|
||||
|
||||
assertThat(appender.list)
|
||||
.as("WARN names the constraint for debuggability")
|
||||
.anySatisfy(e -> assertThat(e.getFormattedMessage()).contains("chk_meta_date_end_after_start"));
|
||||
assertThat(appender.list)
|
||||
.as("but never the SQL statement or values")
|
||||
.noneSatisfy(e -> assertThat(e.getFormattedMessage()).contains("could not execute statement"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Raw-SQL constraint tests for geschichten — deliberately NOT @Transactional at
|
||||
* class level (see JourneyItemConstraintsTest for the rationale).
|
||||
*
|
||||
* The V75 CHECK is the atomic backstop for GeschichteService.MAX_INTRO_LENGTH on
|
||||
* the verbatim JOURNEY intro write path. STORY bodies are intentionally exempt.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class GeschichteConstraintsTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
|
||||
private UUID insertGeschichte(String type, String body) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO geschichten (id, title, body, status, type, created_at, updated_at) "
|
||||
+ "VALUES (?, ?, ?, 'DRAFT', ?, now(), now())",
|
||||
id, "Constraints-Test", body, type);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Test
|
||||
void journey_intro_check_rejects_4001_chars() {
|
||||
assertThatThrownBy(() -> insertGeschichte("JOURNEY", "x".repeat(4001)))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void journey_intro_check_accepts_exactly_4000_chars() {
|
||||
UUID id = insertGeschichte("JOURNEY", "x".repeat(4000));
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void story_bodies_are_not_constrained_by_the_intro_check() {
|
||||
UUID id = insertGeschichte("STORY", "<p>" + "x".repeat(4001) + "</p>");
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,13 @@ package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
@@ -21,22 +19,25 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.nullValue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(GeschichteController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -47,11 +48,9 @@ class GeschichteControllerTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@MockitoBean
|
||||
GeschichteService geschichteService;
|
||||
|
||||
@MockitoBean
|
||||
CustomUserDetailsService customUserDetailsService;
|
||||
@MockitoBean GeschichteService geschichteService;
|
||||
@MockitoBean JourneyItemService journeyItemService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||
|
||||
@@ -65,7 +64,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_returns200_forReader() throws Exception {
|
||||
when(geschichteService.list(any(), any(), any(), anyInt()))
|
||||
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
|
||||
.thenReturn(List.of(summaryStub("Story A")));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -107,7 +106,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns200_whenFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
|
||||
when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
.andExpect(status().isOk())
|
||||
@@ -119,7 +118,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getById(id))
|
||||
when(geschichteService.getView(id))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
@@ -151,7 +150,7 @@ class GeschichteControllerTest {
|
||||
void create_returns201_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(draft(id, "New"));
|
||||
.thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("New");
|
||||
@@ -179,7 +178,7 @@ class GeschichteControllerTest {
|
||||
void update_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(published(id, "Updated"));
|
||||
.thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -208,31 +207,202 @@ class GeschichteControllerTest {
|
||||
verify(geschichteService).delete(id);
|
||||
}
|
||||
|
||||
// ─── POST /api/geschichten/{id}/items ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void appendItem_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void appendItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns201_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Note\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value(itemId.toString()))
|
||||
.andExpect(jsonPath("$.position").value(10));
|
||||
}
|
||||
|
||||
// ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, "Updated"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Updated\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value("Updated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, null));
|
||||
|
||||
// Raw JSON — local objectMapper lacks JsonNullableModule
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\": null}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value(nullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns204_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(journeyItemService).delete(id, itemId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
|
||||
.when(journeyItemService).delete(id, itemId);
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[]}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void reorderItems_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));
|
||||
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[\"" + itemId + "\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(itemId.toString()));
|
||||
}
|
||||
|
||||
// ─── error mapping ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns409_on_position_conflict() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte published(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.body("<p>x</p>")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.build();
|
||||
private JourneyItemView itemViewStub(UUID id, int position, String note) {
|
||||
return new JourneyItemView(id, position, null, note);
|
||||
}
|
||||
|
||||
private Geschichte draft(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.build();
|
||||
private GeschichteView viewStub(UUID id, String title) {
|
||||
return viewStub(id, title, GeschichteStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
|
||||
return new GeschichteView(id, title, "<p>x</p>",
|
||||
status, GeschichteType.STORY,
|
||||
null, new HashSet<>(), List.of(),
|
||||
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
|
||||
}
|
||||
|
||||
/** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
|
||||
private GeschichteSummary summaryStub(String title) {
|
||||
return new GeschichteSummary() {
|
||||
public UUID getId() { return UUID.randomUUID(); }
|
||||
public String getTitle() { return title; }
|
||||
public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; }
|
||||
public GeschichteType getType() { return GeschichteType.STORY; }
|
||||
public AuthorSummary getAuthor() { return null; }
|
||||
public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
|
||||
public LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
|
||||
public String getBody() { return null; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.user.UserGroup;
|
||||
import org.raddatz.familienarchiv.user.UserGroupRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Verifies Geschichte HTTP behaviour end-to-end at the real servlet layer.
|
||||
*
|
||||
* <p>No {@code @Transactional} at class level — that would keep a session open and
|
||||
* mask LazyInitializationException caused by open-in-view: false. Each test seeds data
|
||||
* directly via repositories and relies on the service's own transaction boundaries.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class GeschichteHttpTest {
|
||||
|
||||
@LocalServerPort int port;
|
||||
@MockitoBean S3Client s3Client;
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired UserGroupRepository userGroupRepository;
|
||||
@Autowired PasswordEncoder passwordEncoder;
|
||||
|
||||
private RestTemplate http;
|
||||
private String baseUrl;
|
||||
|
||||
private static final String WRITER_EMAIL = "geschichten-http-writer@test.de";
|
||||
private static final String WRITER_PASSWORD = "pass!Geschichte1";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
http = noThrowRestTemplate();
|
||||
baseUrl = "http://localhost:" + port;
|
||||
geschichteRepository.deleteAll();
|
||||
appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete);
|
||||
appUserRepository.findByEmail(BLOG_WRITER_EMAIL).ifPresent(appUserRepository::delete);
|
||||
userGroupRepository.findByName("HttpTest-BlogWriters").ifPresent(userGroupRepository::delete);
|
||||
appUserRepository.save(AppUser.builder()
|
||||
.email(WRITER_EMAIL)
|
||||
.password(passwordEncoder.encode(WRITER_PASSWORD))
|
||||
.build());
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void list_returns_200_and_empty_array_when_no_stories_exist() {
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten", HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).isEqualTo("[]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_returns_200_and_does_not_500_when_stories_have_journey_items() {
|
||||
// Seed a JOURNEY directly — items are LAZY; without @Transactional(readOnly=true) +
|
||||
// Hibernate.initialize in getById() this would 500. list() uses a projection so it
|
||||
// must also never touch items.
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Reise durch die Briefe")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(journey)
|
||||
.position(1000)
|
||||
.note("Einleitung")
|
||||
.build();
|
||||
journey.getItems().add(item);
|
||||
geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten", HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).contains("Reise durch die Briefe");
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getById_returns_200_with_items_and_does_not_500_open_in_view_false() {
|
||||
// This test is the canonical guard against LazyInitializationException.
|
||||
// open-in-view: false means the Hibernate session is closed when Jackson serializes.
|
||||
// GeschichteService.getById() must initialize items inside its @Transactional boundary.
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Familiengeschichte")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
JourneyItem note = JourneyItem.builder()
|
||||
.geschichte(journey).position(1000).note("Prolog").build();
|
||||
JourneyItem note2 = JourneyItem.builder()
|
||||
.geschichte(journey).position(2000).note("Epilog").build();
|
||||
journey.getItems().add(note);
|
||||
journey.getItems().add(note2);
|
||||
Geschichte saved = geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody())
|
||||
.contains("Familiengeschichte")
|
||||
.contains("Prolog")
|
||||
.contains("Epilog");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returns_404_for_unknown_id() {
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + UUID.randomUUID(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||
assertThat(response.getBody()).contains("GESCHICHTE_NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE() {
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte draft = Geschichte.builder()
|
||||
.title("Geheimer Entwurf")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.author(writer)
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
Geschichte saved = geschichteRepository.save(draft);
|
||||
|
||||
// Writer lacks explicit BLOG_WRITE permission in the app_users table,
|
||||
// so from the service's perspective they're a reader.
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||
}
|
||||
|
||||
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void update_returns_200_and_serializes_items_open_in_view_false() {
|
||||
// Canonical guard for the write path: PATCH must not 500 when the response
|
||||
// is serialized after the service transaction closed. The raw entity carries
|
||||
// a dead lazy items proxy at that point — the endpoint must answer with a
|
||||
// view assembled inside the transaction.
|
||||
AppUser writer = blogWriter();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Reise vor dem Umbenennen")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
journey.getItems().add(JourneyItem.builder()
|
||||
.geschichte(journey).position(1000).note("Prolog").build());
|
||||
Geschichte saved = geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAs(BLOG_WRITER_EMAIL, BLOG_WRITER_PASSWORD);
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.PATCH,
|
||||
new HttpEntity<>("{\"title\":\"Reise nach dem Umbenennen\"}", csrfJsonHeaders(session)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody())
|
||||
.contains("Reise nach dem Umbenennen")
|
||||
.contains("Prolog");
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static final String BLOG_WRITER_EMAIL = "geschichten-http-blogwriter@test.de";
|
||||
private static final String BLOG_WRITER_PASSWORD = "pass!Geschichte2";
|
||||
|
||||
/** A user whose group actually grants BLOG_WRITE — unlike the plain writer above. */
|
||||
private AppUser blogWriter() {
|
||||
UserGroup group = userGroupRepository.save(UserGroup.builder()
|
||||
.name("HttpTest-BlogWriters")
|
||||
.permissions(new HashSet<>(Set.of("BLOG_WRITE")))
|
||||
.build());
|
||||
return appUserRepository.save(AppUser.builder()
|
||||
.email(BLOG_WRITER_EMAIL)
|
||||
.password(passwordEncoder.encode(BLOG_WRITER_PASSWORD))
|
||||
.groups(new HashSet<>(Set.of(group)))
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Session cookie + double-submit CSRF pair + JSON content type for write requests. */
|
||||
private HttpHeaders csrfJsonHeaders(String sessionId) {
|
||||
String xsrf = UUID.randomUUID().toString();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrf);
|
||||
headers.set("X-XSRF-TOKEN", xsrf);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String loginAsWriter() {
|
||||
return loginAs(WRITER_EMAIL, WRITER_PASSWORD);
|
||||
}
|
||||
|
||||
private String loginAs(String email, String password) {
|
||||
String xsrf = UUID.randomUUID().toString();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Cookie", "XSRF-TOKEN=" + xsrf);
|
||||
headers.set("X-XSRF-TOKEN", xsrf);
|
||||
String body = "{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}";
|
||||
ResponseEntity<String> resp = http.postForEntity(
|
||||
baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class);
|
||||
return extractFaSessionCookie(resp);
|
||||
}
|
||||
|
||||
private HttpHeaders sessionHeaders(String sessionId) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Cookie", "fa_session=" + sessionId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
||||
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||
if (setCookieHeader == null) return "";
|
||||
return setCookieHeader.stream()
|
||||
.filter(c -> c.startsWith("fa_session="))
|
||||
.map(c -> c.split(";")[0].substring("fa_session=".length()))
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
private RestTemplate noThrowRestTemplate() {
|
||||
// JDK HttpClient factory — the default HttpURLConnection factory cannot send PATCH.
|
||||
RestTemplate template = new RestTemplate(new JdkClientHttpRequestFactory());
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(ClientHttpResponse response) throws IOException {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemRepository;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class GeschichteListProjectionTest {
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
|
||||
AppUser author;
|
||||
AppUser otherAuthor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
geschichteRepository.deleteAll();
|
||||
author = appUserRepository.save(AppUser.builder()
|
||||
.email("author@test").password("pw").build());
|
||||
otherAuthor = appUserRepository.save(AppUser.builder()
|
||||
.email("other@test").password("pw").build());
|
||||
}
|
||||
|
||||
// ─── findSummaries returns only the requested status ─────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_returns_only_published_stories_when_effectiveStatus_is_PUBLISHED() {
|
||||
geschichteRepository.save(published("Veröffentlicht", author));
|
||||
geschichteRepository.save(draft("Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_carries_updatedAt_for_dashboard_relative_times() {
|
||||
// ReaderDraftsModule renders "bearbeitet vor X" from updatedAt — the
|
||||
// projection must carry it for drafts, where publishedAt is null.
|
||||
geschichteRepository.save(draft("Mein Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getUpdatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
|
||||
geschichteRepository.save(draft("Nur Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── AuthorSummary nested projection ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_exposes_nested_author_names_but_never_email() {
|
||||
AppUser richAuthor = appUserRepository.save(AppUser.builder()
|
||||
.firstName("Franz").lastName("Raddatz")
|
||||
.email("franz@raddatz.de").password("pw").build());
|
||||
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
|
||||
assertThat(a.getFirstName()).isEqualTo("Franz");
|
||||
assertThat(a.getLastName()).isEqualTo("Raddatz");
|
||||
// Design rule (GeschichteView.AuthorView javadoc): author projections never
|
||||
// expose email or group memberships to readers.
|
||||
assertThat(GeschichteSummary.AuthorSummary.class.getMethods())
|
||||
.extracting(java.lang.reflect.Method::getName)
|
||||
.doesNotContain("getEmail");
|
||||
}
|
||||
|
||||
// ─── GeschichteType is exposed ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_exposes_type_field() {
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Eine Reise")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(author)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
geschichteRepository.save(journey);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
// ─── authorId filter (own-drafts gate) ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_authorId_returns_only_own_drafts() {
|
||||
geschichteRepository.save(draft("Mein Entwurf", author));
|
||||
geschichteRepository.save(draft("Fremder Entwurf", otherAuthor));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf");
|
||||
}
|
||||
|
||||
// ─── personCount = 0 → no person filter ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_personCount_zero_ignores_personIds_and_returns_all() {
|
||||
geschichteRepository.save(published("A", author));
|
||||
geschichteRepository.save(published("B", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
}
|
||||
|
||||
// ─── personCount > 0 AND-semantics ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_one_personId_returns_only_linked_stories() {
|
||||
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
|
||||
|
||||
Geschichte withFranz = published("Franz story", author);
|
||||
withFranz.getPersons().add(franz);
|
||||
geschichteRepository.save(withFranz);
|
||||
|
||||
Geschichte withAnna = published("Anna story", author);
|
||||
withAnna.getPersons().add(anna);
|
||||
geschichteRepository.save(withAnna);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Franz story");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_with_two_personIds_uses_AND_semantics() {
|
||||
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
|
||||
|
||||
Geschichte both = published("Both", author);
|
||||
both.getPersons().add(franz);
|
||||
both.getPersons().add(anna);
|
||||
geschichteRepository.save(both);
|
||||
|
||||
Geschichte onlyFranz = published("Only Franz", author);
|
||||
onlyFranz.getPersons().add(franz);
|
||||
geschichteRepository.save(onlyFranz);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Both");
|
||||
}
|
||||
|
||||
// ─── documentId filter (JPQL EXISTS subquery) ────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_documentId_returns_journey_containing_that_document() {
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
Geschichte withDoc = geschichteRepository.save(journey("Reise mit Dokument", author));
|
||||
Geschichte withoutDoc = geschichteRepository.save(journey("Reise ohne Dokument", author));
|
||||
journeyItemRepository.save(JourneyItem.builder()
|
||||
.geschichte(withDoc).document(doc).position(1).build());
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, doc.getId());
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Reise mit Dokument");
|
||||
assertThat(result).extracting(GeschichteSummary::getTitle).doesNotContain("Reise ohne Dokument");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_with_unknown_documentId_returns_empty() {
|
||||
geschichteRepository.save(journey("Irgendeine Reise", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, UUID.randomUUID());
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte published(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte draft(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.author(writer)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte journey(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
|
||||
private List<UUID> sentinel() {
|
||||
return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GeschichteQueryServiceTest {
|
||||
|
||||
@Mock
|
||||
GeschichteRepository geschichteRepository;
|
||||
|
||||
@InjectMocks
|
||||
GeschichteQueryService geschichteQueryService;
|
||||
|
||||
@Test
|
||||
void existsById_returns_true_when_geschichte_exists() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.existsById(id)).thenReturn(true);
|
||||
|
||||
assertThat(geschichteQueryService.existsById(id)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsById_returns_false_when_geschichte_does_not_exist() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.existsById(id)).thenReturn(false);
|
||||
|
||||
assertThat(geschichteQueryService.existsById(id)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteView;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -39,6 +42,7 @@ class GeschichteServiceIntegrationTest {
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired GeschichteService geschichteService;
|
||||
@Autowired JourneyItemService journeyItemService;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@@ -76,11 +80,11 @@ class GeschichteServiceIntegrationTest {
|
||||
+ "<script>alert('xss')</script>");
|
||||
dto.setPersonIds(List.of(franz.getId()));
|
||||
|
||||
Geschichte created = geschichteService.create(dto);
|
||||
GeschichteView created = geschichteService.create(dto);
|
||||
|
||||
assertThat(created.getId()).isNotNull();
|
||||
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(created.getBody())
|
||||
assertThat(created.id()).isNotNull();
|
||||
assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(created.body())
|
||||
.contains("<strong>jeden Sonntag</strong>")
|
||||
.doesNotContain("<script>");
|
||||
|
||||
@@ -89,7 +93,7 @@ class GeschichteServiceIntegrationTest {
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
||||
|
||||
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
||||
UUID draftId = created.getId();
|
||||
UUID draftId = created.id();
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
|
||||
.hasMessageContaining("not found");
|
||||
|
||||
@@ -97,16 +101,17 @@ class GeschichteServiceIntegrationTest {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
|
||||
publishDto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
|
||||
assertThat(publishedGesch.getPublishedAt()).isNotNull();
|
||||
GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
|
||||
assertThat(publishedGesch.publishedAt()).isNotNull();
|
||||
|
||||
// Reader can now see and fetch it
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
||||
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
||||
Geschichte fetched = geschichteService.getById(draftId);
|
||||
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
|
||||
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
|
||||
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId());
|
||||
|
||||
// Delete as writer; join rows go with it
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
@@ -137,17 +142,17 @@ class GeschichteServiceIntegrationTest {
|
||||
|
||||
// No filter → all three
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// Single filter (Anna) → all three
|
||||
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactly(storyAB);
|
||||
|
||||
// AND: Bertha AND Carl → none (no story has both)
|
||||
@@ -174,7 +179,7 @@ class GeschichteServiceIntegrationTest {
|
||||
geschichteService.create(dto);
|
||||
|
||||
authenticateAs(writer2, Permission.BLOG_WRITE);
|
||||
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
@@ -185,7 +190,7 @@ class GeschichteServiceIntegrationTest {
|
||||
dto.setBody("<p>body</p>");
|
||||
dto.setPersonIds(personIds);
|
||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
return geschichteService.create(dto).getId();
|
||||
return geschichteService.create(dto).id();
|
||||
}
|
||||
|
||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||
|
||||
@@ -7,26 +7,22 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -37,7 +33,10 @@ import java.util.stream.Collectors;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -45,17 +44,13 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GeschichteServiceTest {
|
||||
|
||||
@Mock
|
||||
GeschichteRepository geschichteRepository;
|
||||
@Mock
|
||||
PersonService personService;
|
||||
@Mock
|
||||
DocumentService documentService;
|
||||
@Mock
|
||||
UserService userService;
|
||||
@Mock GeschichteRepository geschichteRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock UserService userService;
|
||||
@Mock JourneyItemService journeyItemService;
|
||||
|
||||
@InjectMocks
|
||||
GeschichteService geschichteService;
|
||||
@InjectMocks GeschichteService geschichteService;
|
||||
|
||||
AppUser writer;
|
||||
AppUser reader;
|
||||
@@ -96,7 +91,8 @@ class GeschichteServiceTest {
|
||||
|
||||
Geschichte result = geschichteService.getById(id);
|
||||
|
||||
assertThat(result).isSameAs(draft);
|
||||
assertThat(result.getId()).isEqualTo(id);
|
||||
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -108,7 +104,8 @@ class GeschichteServiceTest {
|
||||
|
||||
Geschichte result = geschichteService.getById(id);
|
||||
|
||||
assertThat(result).isSameAs(published);
|
||||
assertThat(result.getId()).isEqualTo(id);
|
||||
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -123,79 +120,175 @@ class GeschichteServiceTest {
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// ─── getView ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getView_returns_assembled_view_and_delegates_to_journeyItemService() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note");
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
|
||||
when(journeyItemService.getItems(id)).thenReturn(List.of(item));
|
||||
|
||||
GeschichteView view = geschichteService.getView(id);
|
||||
|
||||
assertThat(view.id()).isEqualTo(id);
|
||||
assertThat(view.items()).containsExactly(item);
|
||||
verify(journeyItemService).getItems(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getView_throws_NOT_FOUND_when_id_unknown() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.getView(id))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_uses_firstName_lastName() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("author@test")
|
||||
.firstName("Hans").lastName("Raddatz").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("Hans Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("anon@test").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("[Unbekannt]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_email_is_not_in_author_view() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("secret@test")
|
||||
.firstName("Max").lastName("M").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
// AuthorView exposes only id + displayName — no email field at all
|
||||
assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class);
|
||||
assertThat(result.author().displayName()).doesNotContain("secret@test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_persons_are_mapped_to_PersonView() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID personId = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setPersons(new HashSet<>(List.of(
|
||||
Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build()
|
||||
)));
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.persons()).hasSize(1);
|
||||
GeschichteView.PersonView pv = result.persons().iterator().next();
|
||||
assertThat(pv.id()).isEqualTo(personId);
|
||||
assertThat(pv.firstName()).isEqualTo("Franz");
|
||||
assertThat(pv.lastName()).isEqualTo("Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_items_are_passed_through() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
// ─── list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
|
||||
geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
// Status pinning lives inside the Specification; we assert end-to-end behaviour
|
||||
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
|
||||
// through the spec-aware repository method.
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
|
||||
GeschichteSummary s1 = mock(GeschichteSummary.class);
|
||||
GeschichteSummary s2 = mock(GeschichteSummary.class);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of(s1, s2));
|
||||
|
||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
assertThat(out).hasSize(2);
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
|
||||
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(personId), null, 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
|
||||
void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID a = UUID.randomUUID();
|
||||
UUID b = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(a, b), null, 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_filters_by_documentId() {
|
||||
void list_passes_documentId_to_repository_as_journey_item_filter() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID documentId = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(), documentId, 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
|
||||
void list_caps_limit_at_max_when_caller_passes_huge_value() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of(mock(GeschichteSummary.class)));
|
||||
|
||||
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
|
||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);
|
||||
|
||||
assertThat(out).hasSizeLessThanOrEqualTo(200);
|
||||
}
|
||||
@@ -213,11 +306,11 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("My Story");
|
||||
dto.setBody("<p>plain text</p>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.getPublishedAt()).isNull();
|
||||
assertThat(saved.getAuthor()).isSameAs(writer);
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.publishedAt()).isNull();
|
||||
assertThat(saved.author().id()).isEqualTo(writer.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -231,9 +324,9 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("XSS attempt");
|
||||
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getBody())
|
||||
assertThat(saved.body())
|
||||
.contains("<p>safe</p>")
|
||||
.doesNotContain("<script>")
|
||||
.doesNotContain("onerror")
|
||||
@@ -252,9 +345,9 @@ class GeschichteServiceTest {
|
||||
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
|
||||
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getBody())
|
||||
assertThat(saved.body())
|
||||
.contains("<h2>Heading</h2>")
|
||||
.contains("<strong>bold</strong>")
|
||||
.contains("<em>italic</em>")
|
||||
@@ -277,28 +370,9 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("Linked");
|
||||
dto.setPersonIds(List.of(personId));
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getPersons()).containsExactly(person);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_resolves_documentIds_via_DocumentService() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(docId).build();
|
||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Linked doc");
|
||||
dto.setDocumentIds(List.of(docId));
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getDocuments()).containsExactly(doc);
|
||||
assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -315,6 +389,202 @@ class GeschichteServiceTest {
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_preserves_JOURNEY_type_from_dto() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("My Journey");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_defaults_to_STORY_when_type_is_null() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("My Story");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
|
||||
// The journey intro is plain text: JourneyReader renders it via Svelte text
|
||||
// interpolation (never {@html}), so the OWASP sanitizer's entity encoding
|
||||
// would corrupt real content ("Müller & Söhne" → "Müller & Söhne") and
|
||||
// re-encode cumulatively on every editor round-trip.
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Winterbriefe");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
dto.setBody("Müller & Söhne, Temperatur < 0");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.JOURNEY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("Temperatur < 0 & Schnee");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_still_sanitizes_STORY_body() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
}
|
||||
|
||||
// ─── length caps ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void create_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("x".repeat(256));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.create(dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_accepts_title_of_exactly_255_chars() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("x".repeat(255));
|
||||
|
||||
assertThat(geschichteService.create(dto).title()).hasSize(255);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("x".repeat(256));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Winterbriefe");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
dto.setBody("x".repeat(4001));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.create(dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_accepts_JOURNEY_intro_of_exactly_4000_chars() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Winterbriefe");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
dto.setBody("x".repeat(4000));
|
||||
|
||||
assertThat(geschichteService.create(dto).body()).hasSize(4000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.JOURNEY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("x".repeat(4001));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_does_not_apply_the_intro_cap_to_STORY_bodies() {
|
||||
// STORY bodies are sanitized Tiptap HTML and intentionally unbounded —
|
||||
// the 4000-char cap exists for the verbatim JOURNEY intro path only.
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>" + "x".repeat(4001) + "</p>");
|
||||
|
||||
assertThat(geschichteService.update(id, dto).body()).contains("<p>");
|
||||
}
|
||||
|
||||
// ─── update ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -330,10 +600,10 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
assertThat(saved.getPublishedAt()).isNotNull();
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
assertThat(saved.publishedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -349,10 +619,10 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setStatus(GeschichteStatus.DRAFT);
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.getPublishedAt()).isNull();
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.publishedAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -366,9 +636,46 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_rejects_type_change_with_409_GESCHICHTE_TYPE_IMMUTABLE() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_accepts_dto_carrying_the_unchanged_type() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setType(GeschichteType.STORY);
|
||||
dto.setTitle("Unverändert getypt");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
|
||||
assertThat(saved.title()).isEqualTo("Unverändert getypt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -426,7 +733,7 @@ class GeschichteServiceTest {
|
||||
.body("<p>body</p>")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -438,7 +745,7 @@ class GeschichteServiceTest {
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.publishedAt(LocalDateTime.now().minusHours(1))
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level.
|
||||
* A DataIntegrityViolationException inside a class-level @Transactional marks the tx
|
||||
* rollback-only and cascades into TransactionSystemException on teardown.
|
||||
* Each test inserts via jdbcTemplate and uses explicit SQL teardown.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class JourneyItemConstraintsTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
private UUID geschichteId;
|
||||
private UUID documentId;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbcTemplate.execute("DELETE FROM journey_items");
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Constraints-Test-Doc")
|
||||
.originalFilename("ct.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
documentId = doc.getId();
|
||||
Geschichte g = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Constraints-Test-Journey")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
geschichteId = g.getId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_constraint_is_deferrable_initially_deferred() {
|
||||
Boolean condeferrable = jdbcTemplate.queryForObject(
|
||||
"SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||
Boolean.class);
|
||||
Boolean condeferred = jdbcTemplate.queryForObject(
|
||||
"SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||
Boolean.class);
|
||||
assertThat(condeferrable).as("constraint must be deferrable").isTrue();
|
||||
assertThat(condeferred).as("constraint must be initially deferred").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_index_rejects_duplicate_document_per_geschichte() {
|
||||
// Atomic backstop for the service-level dedup pre-check (check-then-insert race).
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 20, documentId))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_index_allows_same_document_in_different_journeys() {
|
||||
Geschichte other = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Andere Lesereise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), other.getId(), 10, documentId);
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM journey_items WHERE document_id = ?", Integer.class, documentId);
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_index_allows_multiple_note_only_items() {
|
||||
// document_id IS NULL rows must not collide — the index is partial.
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, "erste Notiz");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 20, "zweite Notiz");
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void note_length_check_rejects_2001_chars() {
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, "x".repeat(2001)))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void note_length_check_accepts_exactly_2000_chars() {
|
||||
// Pins the boundary at the DB layer too — a future <= vs < migration edit
|
||||
// must fail here, not only in the mock-based service test.
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, "x".repeat(2000));
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void position_check_rejects_nonpositive() {
|
||||
UUID itemId = UUID.randomUUID();
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
itemId, geschichteId, 0, "test"))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_constraint_rejects_duplicate_position_per_geschichte() {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class JourneyItemDocumentDeleteTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@MockitoBean
|
||||
AuditService auditService;
|
||||
|
||||
@MockitoSpyBean
|
||||
DocumentRepository documentRepository;
|
||||
|
||||
@PersistenceContext
|
||||
EntityManager em;
|
||||
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired DocumentRepository docRepo;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
|
||||
Geschichte journey;
|
||||
Document doc;
|
||||
AppUser writer;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
writer = appUserRepository.save(AppUser.builder()
|
||||
.email("delete-test-writer@test")
|
||||
.password("hash")
|
||||
.build());
|
||||
doc = docRepo.save(Document.builder()
|
||||
.title("Testbrief")
|
||||
.originalFilename("testbrief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
journey = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Eine Lesereise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(writer.getEmail(), null,
|
||||
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
SecurityContextHolder.clearContext();
|
||||
reset(documentRepository);
|
||||
// Deletion order is FK-load-bearing: journey_items reference both documents
|
||||
// and geschichten, so children must be removed before their parents.
|
||||
journeyItemRepository.deleteAll();
|
||||
docRepo.deleteAll();
|
||||
geschichteRepository.deleteAll();
|
||||
appUserRepository.deleteAll();
|
||||
}
|
||||
|
||||
// ─── AC-1: headline ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_linked_via_note_less_item_deletes_item_not_500() {
|
||||
JourneyItem item = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
|
||||
assertThat(journeyItemRepository.findById(item.getId())).isEmpty();
|
||||
assertThat(docRepo.findById(doc.getId())).isEmpty();
|
||||
}
|
||||
|
||||
// ─── AC-2: note-carrying item survives as placeholder ─────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_preserves_note_carrying_item_as_placeholder() {
|
||||
JourneyItem item = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).note("curator context").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
JourneyItem surviving = journeyItemRepository.findById(item.getId()).orElseThrow();
|
||||
assertThat(surviving.getDocumentId()).isNull();
|
||||
assertThat(surviving.getNote()).isEqualTo("curator context");
|
||||
}
|
||||
|
||||
// ─── AC-3: note-only item untouched ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_does_not_affect_note_only_item() {
|
||||
JourneyItem noteOnly = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).note("Einleitung").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
JourneyItem reloaded = journeyItemRepository.findById(noteOnly.getId()).orElseThrow();
|
||||
assertThat(reloaded.getDocumentId()).isNull();
|
||||
assertThat(reloaded.getNote()).isEqualTo("Einleitung");
|
||||
}
|
||||
|
||||
// ─── AC-4: asymmetric multi-journey ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_applies_independently_per_referencing_item() {
|
||||
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Zweite Reise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
|
||||
JourneyItem noteLess = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
JourneyItem noteCarrying = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey2).position(10).document(doc).note("Begleittext").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemRepository.findById(noteLess.getId())).isEmpty();
|
||||
JourneyItem surviving = journeyItemRepository.findById(noteCarrying.getId()).orElseThrow();
|
||||
assertThat(surviving.getDocumentId()).isNull();
|
||||
assertThat(surviving.getNote()).isEqualTo("Begleittext");
|
||||
}
|
||||
|
||||
// ─── AC-5: rollback guard ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void listener_deletes_roll_back_when_document_delete_fails() {
|
||||
JourneyItem item = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
em.clear();
|
||||
|
||||
doThrow(new RuntimeException("simulated failure"))
|
||||
.when(documentRepository).deleteById(any());
|
||||
|
||||
assertThatThrownBy(() -> documentService.deleteDocument(doc.getId(), writer.getId()))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
|
||||
em.clear();
|
||||
assertThat(journeyItemRepository.findById(item.getId())).isPresent();
|
||||
}
|
||||
|
||||
// ─── AC-6: empty-string note boundary ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void empty_string_note_item_is_cascaded_whitespace_only_note_is_preserved() {
|
||||
// uq_journey_items_geschichte_document prevents two items with the same
|
||||
// (geschichte_id, document_id) in one journey — use two separate journeys.
|
||||
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Zweite Reise für AC-6")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
|
||||
UUID emptyNoteItemId = UUID.randomUUID();
|
||||
UUID whitespaceNoteItemId = UUID.randomUUID();
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
|
||||
emptyNoteItemId, journey.getId(), 10, doc.getId(), "");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
|
||||
whitespaceNoteItemId, journey2.getId(), 20, doc.getId(), " ");
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemRepository.findById(emptyNoteItemId)).isEmpty();
|
||||
JourneyItem whitespaceItem = journeyItemRepository.findById(whitespaceNoteItemId).orElseThrow();
|
||||
assertThat(whitespaceItem.getDocumentId()).isNull();
|
||||
assertThat(whitespaceItem.getNote()).isEqualTo(" ");
|
||||
}
|
||||
|
||||
// ─── Idempotency / no-collateral ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_in_zero_journeys_returns_no_collateral() {
|
||||
Document unlinked = docRepo.save(Document.builder()
|
||||
.title("Unverknüpfter Brief")
|
||||
.originalFilename("other.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
JourneyItem unrelated = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).note("unrelated note").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(unlinked.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
assertThat(docRepo.findById(unlinked.getId())).isEmpty();
|
||||
assertThat(journeyItemRepository.findById(unrelated.getId())).isPresent();
|
||||
assertThat(journeyItemRepository.count()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// ─── AC-7: audit — DOCUMENT_DELETED emitted, JOURNEY_ITEM_REMOVED absent ─
|
||||
|
||||
@Test
|
||||
void deleting_document_emits_document_audit_but_no_journey_item_audit() {
|
||||
journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.DOCUMENT_DELETED), eq(writer.getId()), eq(doc.getId()), any());
|
||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), any(), any(), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class JourneyItemIntegrationTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@MockitoBean
|
||||
AuditService auditService;
|
||||
|
||||
@PersistenceContext
|
||||
EntityManager em;
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
@Autowired JourneyItemService journeyItemService;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
|
||||
Geschichte journey;
|
||||
Document doc;
|
||||
AppUser writer;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
writer = appUserRepository.save(AppUser.builder()
|
||||
.email("journey-writer@test")
|
||||
.password("hash")
|
||||
.build());
|
||||
doc = documentRepository.save(Document.builder()
|
||||
.title("Testbrief")
|
||||
.originalFilename("testbrief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
journey = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Eine Lesereise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurity() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||
var authorities = java.util.Arrays.stream(permissions)
|
||||
.map(p -> new SimpleGrantedAuthority(p.name()))
|
||||
.toList();
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
|
||||
}
|
||||
|
||||
// ─── @OrderBy ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void items_are_returned_in_position_order_regardless_of_insertion_order() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
|
||||
// Distinct content per item — V74's partial unique index forbids the same
|
||||
// document twice in one journey, and ordering doesn't depend on it.
|
||||
JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
|
||||
JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).note("erstes").build();
|
||||
JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).note("zweites").build();
|
||||
managed.getItems().addAll(List.of(third, first, second));
|
||||
geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
List<Integer> positions = reloaded.getItems().stream().map(JourneyItem::getPosition).toList();
|
||||
|
||||
assertThat(positions).containsExactly(1000, 2000, 3000);
|
||||
}
|
||||
|
||||
// ─── Cascade ALL + orphanRemoval ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_geschichte_cascade_deletes_all_journey_items() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(1000).document(doc).build());
|
||||
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(2000).note("context").build());
|
||||
geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
UUID geschichteId = journey.getId();
|
||||
geschichteRepository.deleteById(geschichteId);
|
||||
em.flush();
|
||||
|
||||
assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void removing_item_from_items_list_triggers_orphan_removal() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
reloaded.getItems().removeIf(i -> i.getId().equals(itemId));
|
||||
geschichteRepository.save(reloaded);
|
||||
em.flush();
|
||||
|
||||
assertThat(journeyItemRepository.findById(itemId)).isEmpty();
|
||||
}
|
||||
|
||||
// ─── GeschichteType round-trip ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void type_persists_as_JOURNEY_and_roundtrips() {
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
assertThat(reloaded.getType()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void type_defaults_to_STORY_for_new_geschichten() {
|
||||
Geschichte story = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Erinnerung")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
Geschichte reloaded = geschichteRepository.findById(story.getId()).orElseThrow();
|
||||
assertThat(reloaded.getType()).isEqualTo(GeschichteType.STORY);
|
||||
}
|
||||
|
||||
// ─── Note-only item (document_id IS NULL) ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void note_only_item_persists_without_document() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem note = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).note("Eine kurze Einleitung.").build();
|
||||
managed.getItems().add(note);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID noteId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
JourneyItem reloaded = journeyItemRepository.findById(noteId).orElseThrow();
|
||||
assertThat(reloaded.getDocumentId()).isNull();
|
||||
assertThat(reloaded.getNote()).isEqualTo("Eine kurze Einleitung.");
|
||||
}
|
||||
|
||||
// ─── Document-backed item exposes documentId ──────────────────────────────
|
||||
|
||||
@Test
|
||||
void document_backed_item_exposes_document_uuid_via_getDocumentId() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).document(doc).build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
JourneyItem reloaded = journeyItemRepository.findById(itemId).orElseThrow();
|
||||
assertThat(reloaded.getDocumentId()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
// ─── ON DELETE SET NULL ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_sets_item_document_to_null_not_delete_item() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).document(doc).note("still here").build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
|
||||
// Route through service so the DocumentDeletingEvent fires and the listener
|
||||
// removes note-less items before ON DELETE SET NULL acts on note-carrying rows.
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow();
|
||||
assertThat(surviving.getDocumentId()).isNull();
|
||||
assertThat(surviving.getNote()).isEqualTo("still here");
|
||||
}
|
||||
|
||||
// ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ─────────
|
||||
|
||||
@Test
|
||||
void saving_item_with_neither_document_nor_note_violates_check_constraint() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem empty = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).build();
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
journeyItemRepository.save(empty);
|
||||
journeyItemRepository.flush();
|
||||
}).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.append — end-to-end persistence ──────────────────
|
||||
|
||||
@Test
|
||||
void append_persists_item_at_position_10() {
|
||||
// Arrange: authenticate as a user with BLOG_WRITE
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("First stop");
|
||||
|
||||
// Act
|
||||
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Assert: item exists in DB at position 10
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
assertThat(view.note()).isEqualTo("First stop");
|
||||
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||
assertThat(persisted).hasSize(1);
|
||||
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||
assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_document_persists_and_rejects_duplicate() {
|
||||
// Covers the document branch of append, including the duplicate guard —
|
||||
// the derived exists query must resolve document.id, which the transient
|
||||
// getDocumentId() getter on JourneyItem shadows for Spring Data.
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(doc.getId());
|
||||
|
||||
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(view.document()).isNotNull();
|
||||
assertThat(view.document().id()).isEqualTo(doc.getId());
|
||||
|
||||
JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO();
|
||||
duplicate.setDocumentId(doc.getId());
|
||||
assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate))
|
||||
.hasFieldOrPropertyWithValue("code",
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
|
||||
}
|
||||
|
||||
// ─── STORY-type Geschichten hold journey items (#795) ────────────────────
|
||||
|
||||
@Test
|
||||
void story_type_can_hold_journey_items_through_service() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
Geschichte story = savedStory();
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(doc.getId());
|
||||
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).id()).isEqualTo(appended.id());
|
||||
assertThat(items.get(0).document().id()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void v72_migrated_story_items_keep_position_order_and_are_removable() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
Geschichte story = savedStory();
|
||||
Document docB = documentRepository.save(Document.builder()
|
||||
.title("Zweiter Brief").originalFilename("b.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
Document docC = documentRepository.save(Document.builder()
|
||||
.title("Dritter Brief").originalFilename("c.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
|
||||
// V72 inserted journey_items rows directly with position gaps — mirror that
|
||||
// by writing through the repository instead of the service.
|
||||
JourneyItem first = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(story).position(10).document(doc).build());
|
||||
JourneyItem second = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(story).position(20).document(docB).build());
|
||||
JourneyItem third = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(story).position(30).document(docC).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemService.getItems(story.getId()))
|
||||
.extracting(JourneyItemView::position)
|
||||
.containsExactly(10, 20, 30);
|
||||
|
||||
journeyItemService.delete(story.getId(), second.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemService.getItems(story.getId()))
|
||||
.extracting(JourneyItemView::id)
|
||||
.containsExactly(first.getId(), third.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void story_item_with_deleted_document_survives_and_remains_deletable() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
Geschichte story = savedStory();
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(doc.getId());
|
||||
// The note keeps chk_journey_item_not_empty satisfied once ON DELETE
|
||||
// SET NULL clears document_id — a note-less item would block the
|
||||
// document delete at the DB instead.
|
||||
dto.setNote("Begleittext");
|
||||
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Route through service so the DocumentDeletingEvent fires (V72 cascade fix).
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).document()).isNull();
|
||||
|
||||
journeyItemService.delete(story.getId(), appended.id());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemService.getItems(story.getId())).isEmpty();
|
||||
}
|
||||
|
||||
private Geschichte savedStory() {
|
||||
return geschichteRepository.save(Geschichte.builder()
|
||||
.title("Eine Geschichte")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.STORY)
|
||||
.build());
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
|
||||
|
||||
@Test
|
||||
void reorder_swaps_positions_atomically() {
|
||||
// Arrange: append two items (pos 10, pos 20)
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO();
|
||||
dto1.setNote("Item one");
|
||||
JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1);
|
||||
|
||||
JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO();
|
||||
dto2.setNote("Item two");
|
||||
JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2);
|
||||
|
||||
assertThat(item1View.position()).isEqualTo(10);
|
||||
assertThat(item2View.position()).isEqualTo(20);
|
||||
|
||||
// Act: reorder with [item2, item1]
|
||||
JourneyReorderDTO reorderDto = new JourneyReorderDTO();
|
||||
reorderDto.setItemIds(List.of(item2View.id(), item1View.id()));
|
||||
List<JourneyItemView> reordered = journeyItemService.reorder(journey.getId(), reorderDto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Assert: item2 is now at position 10, item1 is at position 20
|
||||
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||
assertThat(persisted).hasSize(2);
|
||||
assertThat(persisted.get(0).getId()).isEqualTo(item2View.id());
|
||||
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||
assertThat(persisted.get(1).getId()).isEqualTo(item1View.id());
|
||||
assertThat(persisted.get(1).getPosition()).isEqualTo(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JourneyItemServiceTest {
|
||||
|
||||
@Mock JourneyItemRepository journeyItemRepository;
|
||||
@Mock GeschichteQueryService geschichteQueryService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock UserService userService;
|
||||
|
||||
@InjectMocks JourneyItemService journeyItemService;
|
||||
|
||||
UUID geschichteId = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID actorId = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setupAuth() {
|
||||
AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
|
||||
lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
|
||||
lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("test@test.de", null,
|
||||
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
|
||||
}
|
||||
|
||||
// ─── toSummary — name composition ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void toSummary_uses_linked_person_firstName_lastName() {
|
||||
Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
|
||||
Document doc = makeDoc(docId, sender, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_falls_back_to_senderText_when_no_person() {
|
||||
Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Familie Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_returns_null_senderName_when_neither_person_nor_text() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(0);
|
||||
assertThat(summary.receiverName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
|
||||
Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
|
||||
Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
|
||||
Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(2);
|
||||
assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_SEASON_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.SEASON);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_APPROX_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.APPROX);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
|
||||
}
|
||||
|
||||
// ─── append ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void append_to_empty_journey_starts_at_10() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_after_reorder_continues_from_max_position() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
|
||||
JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_neither_documentId_nor_note() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("documentId or note");
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_note_trims_to_empty_and_no_document() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote(" \n ");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
|
||||
// 2000 is the spec'd limit (frontend maxlength + i18n message agree) — see #793.
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("x".repeat(2001));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_accepts_note_of_exactly_2000_chars() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "x".repeat(2000));
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("x".repeat(2000));
|
||||
|
||||
assertThat(journeyItemService.append(geschichteId, dto).note()).hasSize(2000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns404_when_documentId_does_not_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(documentService.findSummaryByIdInternal(docId))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_when_100_items_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_when_document_already_in_journey() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_to_STORY_type_creates_journey_item() {
|
||||
Geschichte story = story(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(false);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
when(documentService.findSummaryByIdInternal(docId)).thenReturn(doc);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(savedItemWithDoc(itemId, story, 10, doc, null));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
assertThat(view.document().id()).isEqualTo(docId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_to_STORY_type_respects_capacity_cap() {
|
||||
Geschichte story = story(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_to_STORY_type_rejects_duplicate_document() {
|
||||
Geschichte story = story(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cap_is_COUNT_based_not_MAX_position_based() {
|
||||
// 99 rows with MAX(position)=2000 should still accept the 100th append
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
|
||||
JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() {
|
||||
// Two concurrent appends can both pass the exists() pre-check; the partial
|
||||
// unique index then rejects the second INSERT at flush. The service must
|
||||
// translate that into the same friendly 409 as the pre-check.
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
||||
when(journeyItemRepository.saveAndFlush(any()))
|
||||
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
|
||||
"duplicate key value violates unique constraint \"uq_journey_items_geschichte_document\""));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(UUID.randomUUID());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() {
|
||||
// An FK violation (document deleted between load and flush) must NOT be
|
||||
// translated into "already added" — only the dedup index earns that 409.
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
||||
when(journeyItemRepository.saveAndFlush(any()))
|
||||
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
|
||||
"insert or update on table \"journey_items\" violates foreign key constraint \"fk_journey_items_document\""));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(UUID.randomUUID());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_audits_JOURNEY_ITEM_ADDED() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
journeyItemService.append(geschichteId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── updateNote ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateNote_absent_leaves_note_unchanged() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Original note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
// note is null by default — absent from JSON, no-op
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isEqualTo("Original note");
|
||||
verify(journeyItemRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_null_clears_note_when_document_is_present() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.empty());
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_string_sets_note() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||
item.setNote(null);
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("New note"));
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isEqualTo("New note");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_null_returns400_when_item_has_no_document() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_whitespace_only_including_newlines_stored_as_null() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("\n \n"));
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void patch_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("x".repeat(2001)));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_auditsNoteUpdate() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("New note"));
|
||||
journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void patch_returns404_when_item_belongs_to_different_journey() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("text"));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||
}
|
||||
|
||||
// ─── delete ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void delete_returns404_when_item_already_deleted() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_no_audit_when_item_not_found() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
journeyItemService.delete(geschichteId, itemId);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── reorder ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void reorder_unknownGeschichteId_throws404() {
|
||||
UUID unknownId = UUID.randomUUID();
|
||||
// geschichteQueryService is not lenient-stubbed for unknownId → returns false
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_itemIds_contain_duplicates() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id1)); // duplicate
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_itemId_belongs_to_different_journey() {
|
||||
UUID foreignId = UUID.randomUUID();
|
||||
UUID localId = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(foreignId));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_ids_have_extra_items() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id2));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns200_when_empty_on_empty_journey() {
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of());
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
List<JourneyItemView> result = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_empty_on_nonempty_journey() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns_items_in_new_order_starting_at_10() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 20, null, "A");
|
||||
JourneyItem item2 = savedItem(id2, journey, 10, null, "B");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1, id2));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item2, item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id2)); // want id1 first
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(2);
|
||||
assertThat(views.get(0).id()).isEqualTo(id1);
|
||||
assertThat(views.get(0).position()).isEqualTo(10);
|
||||
assertThat(views.get(1).id()).isEqualTo(id2);
|
||||
assertThat(views.get(1).position()).isEqualTo(20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_identical_order_returns200() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1));
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(1);
|
||||
assertThat(views.get(0).position()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_of_grandfathered_over_cap_journey_succeeds() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
// 130-item journey — reorder with all 130 IDs must succeed despite > 100 cap
|
||||
List<UUID> ids = new java.util.ArrayList<>();
|
||||
List<JourneyItem> items = new java.util.ArrayList<>();
|
||||
for (int i = 1; i <= 130; i++) {
|
||||
UUID id = UUID.randomUUID();
|
||||
ids.add(id);
|
||||
items.add(savedItem(id, journey, i * 10, null, "item " + i));
|
||||
}
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(new HashSet<>(ids));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(items);
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(ids);
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(130);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_audits_JOURNEY_ITEMS_REORDERED() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1));
|
||||
journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte journey(UUID id) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title("Test Journey")
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte story(UUID id) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title("Test Story")
|
||||
.type(GeschichteType.STORY)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build();
|
||||
}
|
||||
|
||||
private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||
return JourneyItem.builder()
|
||||
.id(id)
|
||||
.geschichte(g)
|
||||
.position(position)
|
||||
.document(null) // no document entity to avoid LAZY issues in unit tests
|
||||
.note(note)
|
||||
.build();
|
||||
}
|
||||
|
||||
private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.id(id)
|
||||
.geschichte(g)
|
||||
.position(position)
|
||||
.document(doc)
|
||||
.note(note)
|
||||
.build();
|
||||
return item;
|
||||
}
|
||||
|
||||
private Document makeDoc(UUID id, Person sender, List<Person> receivers, String senderText, String receiverText) {
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Test Doc")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.senderText(senderText)
|
||||
.receiverText(receiverText)
|
||||
.sender(sender)
|
||||
.build();
|
||||
doc.setReceivers(new HashSet<>(receivers));
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentTitleFactory;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
@@ -59,8 +60,10 @@ class DocumentImporterTest {
|
||||
// override this stub locally (load_skipsFile_whenMagicByteCheckThrowsIoException).
|
||||
lenient().when(fileStreamOpener.open(any(File.class)))
|
||||
.thenAnswer(inv -> new java.io.FileInputStream(inv.getArgument(0, File.class)));
|
||||
importer = new DocumentImporter(documentService, personService, tagService, s3Client,
|
||||
thumbnailAsyncRunner, fileStreamOpener);
|
||||
// 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");
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import jakarta.persistence.PersistenceContext;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -120,37 +121,60 @@ class PersonRepositoryTest {
|
||||
.containsExactly("Anna", "Clara");
|
||||
}
|
||||
|
||||
// ─── findByAliasIgnoreCase ────────────────────────────────────────────────
|
||||
// ─── findByAlias (exact) / findAllByAliasIgnoreCase (case-folding siblings) ───
|
||||
|
||||
@Test
|
||||
void findByAliasIgnoreCase_returnsMatchingPerson() {
|
||||
void findByAlias_returnsExactCaseMatchOnly() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Karl").lastName("Brandt").alias("Opa Karl").build());
|
||||
|
||||
Optional<Person> found = personRepository.findByAliasIgnoreCase("opa karl");
|
||||
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getFirstName()).isEqualTo("Karl");
|
||||
assertThat(personRepository.findByAlias("Opa Karl")).isPresent();
|
||||
assertThat(personRepository.findByAlias("opa karl")).isEmpty(); // exact-case: a folded form does NOT match
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
|
||||
Optional<Person> found = personRepository.findByAliasIgnoreCase("nobody");
|
||||
|
||||
assertThat(found).isEmpty();
|
||||
void findAllByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
|
||||
assertThat(personRepository.findAllByAliasIgnoreCase("nobody")).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
|
||||
void findByFirstNameIgnoreCaseAndLastNameIgnoreCase_returnsMatch() {
|
||||
void findByFirstNameAndLastName_returnsExactCaseMatchOnly() {
|
||||
personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());
|
||||
|
||||
Optional<Person> found = personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(
|
||||
"maria", "raddatz");
|
||||
assertThat(personRepository.findByFirstNameAndLastName("Maria", "Raddatz")).isPresent();
|
||||
assertThat(personRepository.findByFirstNameAndLastName("maria", "raddatz")).isEmpty(); // exact-case only
|
||||
}
|
||||
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getFirstName()).isEqualTo("Maria");
|
||||
@Test
|
||||
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_foldsUmlautCase_inRealPostgres() {
|
||||
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
|
||||
|
||||
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase("HANS", "MÜLLER"))
|
||||
.hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase_nullFirstName_foldsToNoMatch() {
|
||||
// Fail-closed: a last-name-only filename (null first name) must NOT widen to first_name IS
|
||||
// NULL and pull in the institution/last-name-only row as a "sender". Proven on real
|
||||
// Postgres because a mocked unit test cannot catch the IS NULL vs `= NULL` semantics.
|
||||
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
|
||||
|
||||
assertThat(personRepository.findAllByFirstNameIgnoreCaseAndLastNameIgnoreCase(null, "Müller"))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
// ─── findCorrespondents ───────────────────────────────────────────────────
|
||||
@@ -366,30 +390,6 @@ class PersonRepositoryTest {
|
||||
assertThat(result).hasSize(1);
|
||||
}
|
||||
|
||||
// ─── deleteReceiverReferences ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleteReceiverReferences_removesPersonFromAllDocumentReceivers() {
|
||||
Person toDelete = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||
|
||||
Document doc1 = documentRepository.save(Document.builder()
|
||||
.title("Brief 1").originalFilename("b1.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender).receivers(Set.of(toDelete)).build());
|
||||
Document doc2 = documentRepository.save(Document.builder()
|
||||
.title("Brief 2").originalFilename("b2.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.sender(sender).receivers(Set.of(toDelete)).build());
|
||||
|
||||
personRepository.deleteReceiverReferences(toDelete.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
|
||||
assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty();
|
||||
}
|
||||
|
||||
// ─── searchByName with aliases ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -428,6 +428,67 @@ class PersonRepositoryTest {
|
||||
assertThat(results).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByName_findsByAliasFirstName() {
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
|
||||
List<Person> results = personRepository.searchByName("Wilhelmina");
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByName_ordersByLastNameThenFirstName() {
|
||||
personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
personRepository.save(Person.builder().firstName("Anna").lastName("Cram").build());
|
||||
personRepository.save(Person.builder().firstName("Bernd").lastName("Cram").build());
|
||||
|
||||
List<Person> results = personRepository.searchByName("Cram");
|
||||
|
||||
assertThat(results).extracting(Person::getFirstName)
|
||||
.containsExactly("Anna", "Bernd", "Clara");
|
||||
}
|
||||
|
||||
// ─── resolveByName fetch→classify, end-to-end on real Postgres (#763 review) ───
|
||||
// The classifier unit tests in PersonServiceTest stub searchByName, so they never prove the
|
||||
// fetch query actually finds an alias-only match and feeds it into classification. These walk
|
||||
// the whole searchByName → resolveByName path over the real Postgres slice, closing AC#4/#5.
|
||||
|
||||
@Test
|
||||
void resolveByName_maidenAlias_classifiesAsDirect_endToEnd() {
|
||||
PersonService personService = new PersonService(personRepository, aliasRepository);
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).lastName("Cram").type(PersonNameAliasType.MAIDEN_NAME).sortOrder(0).build());
|
||||
// Detach so resolveByName re-fetches with its lazy nameAliases loaded from the DB —
|
||||
// the fresh-session behaviour the @Transactional(readOnly=true) path has in production.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
NameMatches matches = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_aliasFirstName_classifiesAsDirect_endToEnd() {
|
||||
PersonService personService = new PersonService(personRepository, aliasRepository);
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
NameMatches matches = personService.resolveByName("Wilhelmina");
|
||||
|
||||
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
|
||||
}
|
||||
|
||||
// ─── searchWithDocumentCount with aliases ────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -707,4 +768,146 @@ class PersonRepositoryTest {
|
||||
assertThat(found).isPresent();
|
||||
assertThat(found.get().getGeneration()).isNull();
|
||||
}
|
||||
|
||||
// ─── #684: ON DELETE integrity enforced at the database layer ──────────────
|
||||
// A raw deleteById (bypassing PersonService) must keep referential integrity:
|
||||
// documents.sender_id → SET NULL, document_receivers.person_id → CASCADE, and the
|
||||
// transcription_block_mentioned_persons soft reference → CASCADE. These run against
|
||||
// real Postgres because the FK ON DELETE behaviour never fires on H2.
|
||||
|
||||
@Test
|
||||
void deleteById_personSenderOfAReceiverOfB_nullsSender_dropsReceiverRow_bothDocumentsSurvive() {
|
||||
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||
Person bystander = personRepository.save(Person.builder().firstName("Bleibt").lastName("Hier").build());
|
||||
|
||||
Document sent = documentRepository.save(Document.builder()
|
||||
.title("Gesendet").originalFilename("sent.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(target).build());
|
||||
Document received = documentRepository.save(Document.builder()
|
||||
.title("Empfangen").originalFilename("received.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(bystander)
|
||||
.receivers(Set.of(target)).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||
|
||||
Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
|
||||
assertThat(reloadedSent.getSender()).isNull(); // AC-1: SET NULL
|
||||
|
||||
Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
|
||||
assertThat(reloadedReceived.getReceivers())
|
||||
.noneMatch(p -> p.getId().equals(target.getId())); // AC-2: CASCADE drops the join row
|
||||
|
||||
// Cascade-boundary guard (Nora, non-negotiable): the cascade stops at the join/reference
|
||||
// layer — both documents themselves survive. Guards against a future migration turning
|
||||
// documents.sender_id SET NULL into CASCADE and destroying historical letters.
|
||||
assertThat(documentRepository.findById(sent.getId())).isPresent();
|
||||
assertThat(documentRepository.findById(received.getId())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteById_receiverWithCoReceiver_dropsOnlyDeletedPersonsJoinRow() {
|
||||
Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(sender)
|
||||
.receivers(Set.of(target, coReceiver)).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||
assertThat(reloaded.getReceivers()).extracting(Person::getId)
|
||||
.containsExactly(coReceiver.getId()); // co-receiver untouched
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteById_personIsSenderAndReceiverOfSameDocument_documentSurvives_senderNull_receiverDropped() {
|
||||
// AC-8: the trickier same-document interaction the cross-document cases don't exercise.
|
||||
Person target = personRepository.save(Person.builder().firstName("Beides").lastName("Person").build());
|
||||
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Selbstbrief").originalFilename("self.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(target)
|
||||
.receivers(Set.of(target, coReceiver)).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||
assertThat(reloaded.getSender()).isNull();
|
||||
assertThat(reloaded.getReceivers()).extracting(Person::getId)
|
||||
.containsExactly(coReceiver.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives() {
|
||||
// AC-3: the @-mention sidecar is a CASCADE soft reference, but the literal "@Name" lives
|
||||
// in transcription_blocks.text and must stay visible as plain text after the person goes.
|
||||
Person mentioned = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build());
|
||||
Person survivor = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf")
|
||||
.status(DocumentStatus.UPLOADED).build());
|
||||
entityManager.flush();
|
||||
|
||||
UUID annotationId = UUID.randomUUID();
|
||||
UUID blockId = UUID.randomUUID();
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) "
|
||||
+ "VALUES (?1, ?2, 1, 0.1, 0.2, 0.3, 0.1, '#fff')")
|
||||
.setParameter(1, annotationId).setParameter(2, doc.getId()).executeUpdate();
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO transcription_blocks (id, annotation_id, document_id, text) VALUES (?1, ?2, ?3, ?4)")
|
||||
.setParameter(1, blockId).setParameter(2, annotationId).setParameter(3, doc.getId())
|
||||
.setParameter(4, "Brief an @Auguste Raddatz und @Clara Cram").executeUpdate();
|
||||
// Two mention rows on the same block: the deleted person and an innocent bystander.
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
|
||||
+ "VALUES (?1, ?2, ?3)")
|
||||
.setParameter(1, blockId).setParameter(2, mentioned.getId())
|
||||
.setParameter(3, "Auguste Raddatz").executeUpdate();
|
||||
entityManager.createNativeQuery(
|
||||
"INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) "
|
||||
+ "VALUES (?1, ?2, ?3)")
|
||||
.setParameter(1, blockId).setParameter(2, survivor.getId())
|
||||
.setParameter(3, "Clara Cram").executeUpdate();
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personRepository.deleteById(mentioned.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
Number mentionRows = (Number) entityManager.createNativeQuery(
|
||||
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
|
||||
.setParameter(1, mentioned.getId()).getSingleResult();
|
||||
assertThat(mentionRows.longValue()).isZero();
|
||||
|
||||
// The cascade is scoped to the deleted person — the bystander's mention row is untouched.
|
||||
Number survivorRows = (Number) entityManager.createNativeQuery(
|
||||
"SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1")
|
||||
.setParameter(1, survivor.getId()).getSingleResult();
|
||||
assertThat(survivorRows.longValue()).isEqualTo(1);
|
||||
|
||||
String text = (String) entityManager.createNativeQuery(
|
||||
"SELECT text FROM transcription_blocks WHERE id = ?1")
|
||||
.setParameter(1, blockId).getSingleResult();
|
||||
assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonType;
|
||||
@@ -16,10 +17,13 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -33,6 +37,7 @@ class PersonServiceIntegrationTest {
|
||||
@Autowired PersonService personService;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired DocumentService documentService;
|
||||
|
||||
@PersistenceContext EntityManager entityManager;
|
||||
|
||||
@@ -75,6 +80,93 @@ class PersonServiceIntegrationTest {
|
||||
assertThat(result.getLastName()).isEqualTo("Cram");
|
||||
}
|
||||
|
||||
// ─── #731: case-colliding alias resolution against real Postgres ───────────
|
||||
// The umlaut pair is mandatory — only the real DB proves Postgres LOWER() folds ü; a
|
||||
// plain-ASCII test would stay green while umlaut aliases regressed.
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_resolvesUmlautAliasCollision_toLowestId_withoutThrow() {
|
||||
Person muller = personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
|
||||
Person mullerLower = personRepository.save(Person.builder().lastName("müller").alias("müller").build());
|
||||
UUID expected = muller.getId().compareTo(mullerLower.getId()) <= 0 ? muller.getId() : mullerLower.getId();
|
||||
|
||||
// No exact-case "MÜLLER" row → falls through to the case-insensitive branch with two
|
||||
// candidates and must pick the lowest id, never throwing NonUniqueResultException.
|
||||
Person resolved = personService.findOrCreateByAlias("MÜLLER");
|
||||
|
||||
assertThat(resolved.getId()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_umlautAliasCollision_isDeterministicAcrossCalls() {
|
||||
personRepository.save(Person.builder().lastName("Müller").alias("Müller").build());
|
||||
personRepository.save(Person.builder().lastName("müller").alias("müller").build());
|
||||
|
||||
Person first = personService.findOrCreateByAlias("MÜLLER");
|
||||
Person second = personService.findOrCreateByAlias("MÜLLER");
|
||||
|
||||
assertThat(second.getId()).isEqualTo(first.getId());
|
||||
}
|
||||
|
||||
// ─── #731: filename-based sender resolution against real Postgres ──────────
|
||||
|
||||
@Test
|
||||
void storeDocument_resolvesSender_whenFilenameNameIsUnique() throws Exception {
|
||||
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
|
||||
Document doc = uploadNamed("1965-03-12_Müller_Hans.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNotNull();
|
||||
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_resolvesSender_onSingleCaseInsensitiveMatch() throws Exception {
|
||||
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
|
||||
// Filename folds to "hans müller"; the only stored person is "Hans Müller".
|
||||
Document doc = uploadNamed("1965-03-12_müller_hans.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNotNull();
|
||||
assertThat(doc.getSender().getId()).isEqualTo(hans.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_leavesSenderUnset_whenFilenameNameIsAmbiguous() throws Exception {
|
||||
// Two persons collide case-insensitively; the filename casing ("HANS"/"MÜLLER") matches
|
||||
// neither exactly → no exact-case winner → bail to null (never an arbitrary guess), no 500.
|
||||
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||
personRepository.save(Person.builder().firstName("hans").lastName("müller").build());
|
||||
|
||||
Document doc = uploadNamed("1965-03-12_MÜLLER_HANS.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_leavesSenderUnset_whenFilenameHasNoFirstName() throws Exception {
|
||||
// A last-name-only filename never resolves to a sender (the parser yields no parsed name).
|
||||
personRepository.save(Person.builder().lastName("Müller").build());
|
||||
|
||||
Document doc = uploadNamed("1965-03-12_Müller.pdf").document();
|
||||
|
||||
assertThat(doc.getSender()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByName_nullFirstName_resolvesToEmpty_inRealPostgres() {
|
||||
// Fail-closed against the real DB: a null first name must NOT widen to first_name IS NULL
|
||||
// and pick up the last-name-only row.
|
||||
personRepository.save(Person.builder().lastName("Müller").build()); // first_name NULL
|
||||
|
||||
assertThat(personService.findByName(null, "Müller")).isEmpty();
|
||||
}
|
||||
|
||||
private DocumentService.StoreResult uploadNamed(String filename) throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", filename, "application/pdf", new byte[]{1, 2, 3});
|
||||
return documentService.storeDocument(file, null);
|
||||
}
|
||||
|
||||
// ─── #667: confirm round-trip + reader-default semantics ──────────────────
|
||||
|
||||
@Test
|
||||
@@ -180,9 +272,9 @@ class PersonServiceIntegrationTest {
|
||||
@Test
|
||||
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
||||
// 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
|
||||
// (reassignSenderToNull → deleteReceiverReferences → deleteById), so no FK orphan and
|
||||
// the documents themselves survive.
|
||||
// cleanly via the service path: deletePerson just calls deleteById, and V71's ON DELETE
|
||||
// constraints null the sender_id FK and drop the receiver join row, so there is no FK
|
||||
// orphan and the documents themselves survive.
|
||||
Person target = personRepository.save(Person.builder()
|
||||
.firstName("Weg").lastName("Person").provisional(true).build());
|
||||
Person bystander = personRepository.save(Person.builder()
|
||||
@@ -196,16 +288,16 @@ class PersonServiceIntegrationTest {
|
||||
.status(DocumentStatus.UPLOADED).sender(bystander)
|
||||
.receivers(new java.util.HashSet<>(Set.of(target))).build());
|
||||
|
||||
// Persist the fixture and detach everything so the native @Modifying deletes operate on
|
||||
// the database directly without the persistence context holding stale references that
|
||||
// would re-flush a now-deleted person as a transient association.
|
||||
// Persist the fixture and detach everything so the delete operates on the database
|
||||
// directly without the persistence context holding stale references.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personService.deletePerson(target.getId());
|
||||
|
||||
// Native @Modifying queries bypass the persistence context — clear it so the asserting
|
||||
// reads observe the post-delete database state, not stale managed entities.
|
||||
// The ON DELETE cascade fires beneath Hibernate — flush the delete and clear the L1
|
||||
// cache so the asserting reads observe the post-delete database state, not stale
|
||||
// managed entities still holding the dropped sender/receiver associations.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
@@ -220,4 +312,38 @@ class PersonServiceIntegrationTest {
|
||||
// The other person and the documents themselves survive the delete.
|
||||
assertThat(personRepository.findById(bystander.getId())).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergePersons_targetInheritsReferences_sourceJoinRowCascadeDrops_noFkError() {
|
||||
// AC-7: merging a source who is sender of A and receiver of B into a target leaves the
|
||||
// target as sender of A and receiver of B, drops the source's leftover receiver row via
|
||||
// V71's ON DELETE CASCADE (no explicit delete, no FK error), and co-receivers are intact.
|
||||
Person source = personRepository.save(Person.builder().firstName("Anna").lastName("Alt").build());
|
||||
Person target = personRepository.save(Person.builder().firstName("Anna").lastName("Neu").build());
|
||||
Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build());
|
||||
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||
|
||||
Document docA = documentRepository.save(Document.builder()
|
||||
.title("Von Anna").originalFilename("a.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(source).build());
|
||||
Document docB = documentRepository.save(Document.builder()
|
||||
.title("An Anna").originalFilename("b.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(sender)
|
||||
.receivers(new java.util.HashSet<>(Set.of(source, coReceiver))).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personService.mergePersons(source.getId(), target.getId());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertThat(personRepository.findById(source.getId())).isEmpty();
|
||||
|
||||
Document reloadedA = documentRepository.findById(docA.getId()).orElseThrow();
|
||||
assertThat(reloadedA.getSender().getId()).isEqualTo(target.getId());
|
||||
|
||||
Document reloadedB = documentRepository.findById(docB.getId()).orElseThrow();
|
||||
assertThat(reloadedB.getReceivers()).extracting(Person::getId)
|
||||
.containsExactlyInAnyOrder(target.getId(), coReceiver.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -147,9 +148,11 @@ class PersonServiceTest {
|
||||
|
||||
personService.deletePerson(id);
|
||||
|
||||
verify(personRepository).reassignSenderToNull(id);
|
||||
verify(personRepository).deleteReceiverReferences(id);
|
||||
// Integrity is enforced by V71's ON DELETE constraints — the service only checks
|
||||
// existence then deletes; it no longer detaches sender/receiver references itself.
|
||||
verify(personRepository).findById(id);
|
||||
verify(personRepository).deleteById(id);
|
||||
verifyNoMoreInteractions(personRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -372,14 +375,57 @@ class PersonServiceTest {
|
||||
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_returnsExisting_whenAliasFound() {
|
||||
String alias = "Walter de Gruyter";
|
||||
Person existing = Person.builder().id(UUID.randomUUID()).alias(alias).build();
|
||||
when(personRepository.findByAliasIgnoreCase(alias)).thenReturn(Optional.of(existing));
|
||||
void findOrCreateByAlias_returnsExactCaseMatch_overCaseInsensitiveSibling() {
|
||||
String alias = "müller";
|
||||
Person exact = Person.builder().id(UUID.randomUUID()).alias("müller").build();
|
||||
when(personRepository.findByAlias(alias)).thenReturn(Optional.of(exact));
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -387,7 +433,8 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_createsNew_whenAliasNotFound() {
|
||||
String alias = "Clara Cram";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.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);
|
||||
|
||||
Person result = personService.findOrCreateByAlias(alias);
|
||||
@@ -400,7 +447,8 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_createsMaidenNameAlias_whenGebPresent() {
|
||||
String alias = "Clara Cram geb. de Gruyter";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.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(aliasRepository.findMaxSortOrder(saved.getId())).thenReturn(0);
|
||||
when(aliasRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
@@ -422,7 +470,8 @@ class PersonServiceTest {
|
||||
@Test
|
||||
void findOrCreateByAlias_setsInstitutionType_withFullNameInLastName() {
|
||||
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 -> {
|
||||
Person p = inv.getArgument(0);
|
||||
p.setId(UUID.randomUUID());
|
||||
@@ -439,7 +488,8 @@ class PersonServiceTest {
|
||||
@Test
|
||||
void findOrCreateByAlias_setsGroupType_withFullNameInLastName() {
|
||||
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 -> {
|
||||
Person p = inv.getArgument(0);
|
||||
p.setId(UUID.randomUUID());
|
||||
@@ -457,7 +507,8 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_noAlias_whenNoGeb() {
|
||||
String alias = "Clara Cram";
|
||||
Person saved = Person.builder().id(UUID.randomUUID()).alias(alias).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.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);
|
||||
|
||||
personService.findOrCreateByAlias(alias);
|
||||
@@ -469,11 +520,54 @@ class PersonServiceTest {
|
||||
void findOrCreateByAlias_trimsInput() {
|
||||
String alias = " Clara Cram ";
|
||||
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);
|
||||
|
||||
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) ────────────────────────────────────────────────
|
||||
@@ -700,10 +794,14 @@ class PersonServiceTest {
|
||||
|
||||
personService.mergePersons(sourceId, targetId);
|
||||
|
||||
verify(personRepository).findById(sourceId);
|
||||
verify(personRepository).findById(targetId);
|
||||
verify(personRepository).reassignSender(sourceId, targetId);
|
||||
verify(personRepository).insertMissingReceiverReference(sourceId, targetId);
|
||||
verify(personRepository).deleteReceiverReferences(sourceId);
|
||||
verify(personRepository).deleteById(sourceId);
|
||||
// The source's leftover receiver rows cascade-drop via V71's ON DELETE CASCADE on
|
||||
// deleteById — merge no longer deletes them explicitly.
|
||||
verifyNoMoreInteractions(personRepository);
|
||||
}
|
||||
|
||||
// ─── getAliases ─────────────────────────────────────────────────────────
|
||||
@@ -800,4 +898,165 @@ class PersonServiceTest {
|
||||
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||
.isEqualTo(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByDisplayNameContaining_delegatesToSearchByName() {
|
||||
Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
|
||||
when(personRepository.searchByName("Walter")).thenReturn(List.of(walter));
|
||||
|
||||
List<Person> result = personService.findByDisplayNameContaining("Walter");
|
||||
|
||||
assertThat(result).containsExactly(walter);
|
||||
verify(personRepository).searchByName("Walter");
|
||||
}
|
||||
|
||||
// ─── tokenize (name-match contract) ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void tokenize_hyphenatedName_splitsOnHyphen() {
|
||||
assertThat(PersonService.tokenize("Anna-Maria")).containsExactly("anna", "maria");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_apostropheName_splitsOnApostrophe() {
|
||||
assertThat(PersonService.tokenize("D'Angelo")).containsExactly("d", "angelo");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_umlautName_lowercasesToSingleToken() {
|
||||
assertThat(PersonService.tokenize("Müller")).containsExactly("müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_doubleSpace_dropsEmptyTokens() {
|
||||
assertThat(PersonService.tokenize("Clara Cram")).containsExactly("clara", "cram");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_allWhitespace_returnsEmpty() {
|
||||
assertThat(PersonService.tokenize(" ")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_null_returnsEmpty() {
|
||||
assertThat(PersonService.tokenize(null)).isEmpty();
|
||||
}
|
||||
|
||||
// ─── resolveByName (direct / partial classification) ──────────────────────
|
||||
|
||||
@Test
|
||||
void resolveByName_singleDirectMatch_classifiesAsDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_maidenAliasToken_classifiesAsDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Müller")
|
||||
.nameAliases(List.of(PersonNameAlias.builder().lastName("Cram")
|
||||
.type(PersonNameAliasType.MAIDEN_NAME).build()))
|
||||
.build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_aliasFirstNameToken_isFetchedAndClassified() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram")
|
||||
.nameAliases(List.of(PersonNameAlias.builder().firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).build()))
|
||||
.build();
|
||||
when(personRepository.searchByName("wilhelmina")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Wilhelmina");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_middleName_stillDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara Maria").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_reorderedTokens_stillDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Cram Clara");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_cramVsCramer_classifiesAsPartial() {
|
||||
Person cramer = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(cramer));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(cramer));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.partial()).containsExactly(cramer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_emptyAfterTokenizing_returnsNoCandidates() {
|
||||
NameMatches result = personService.resolveByName(" - ");
|
||||
|
||||
assertThat(result.direct()).isEmpty();
|
||||
verify(personRepository, never()).searchByName(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_directSortsBeyondCap_stillReturnedAsDirect() {
|
||||
List<Person> pool = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) {
|
||||
pool.add(Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build());
|
||||
}
|
||||
Person direct = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
pool.add(direct);
|
||||
when(personRepository.searchByName("clara")).thenReturn(pool);
|
||||
when(personRepository.searchByName("cram")).thenReturn(pool);
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(direct);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_over8Tokens_issuesAtMost8Fetches() {
|
||||
personService.resolveByName("a b c d e f g h i j");
|
||||
|
||||
verify(personRepository, org.mockito.Mockito.atMost(8)).searchByName(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_samePersonFromTwoTokens_appearsOnce() {
|
||||
// Both token fetches return the same person id — fetchPool's putIfAbsent must dedup so the
|
||||
// candidate is classified once, not twice.
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).hasSize(1);
|
||||
assertThat(result.partial()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ class TagControllerTest {
|
||||
void getTagTree_returns200_withTreeStructure() throws Exception {
|
||||
UUID parentId = UUID.randomUUID();
|
||||
UUID childId = UUID.randomUUID();
|
||||
TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, List.of(), parentId);
|
||||
TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, List.of(child), null);
|
||||
TagTreeNodeDTO child = new TagTreeNodeDTO(childId, "Haus", null, 0, 0, List.of(), parentId);
|
||||
TagTreeNodeDTO parent = new TagTreeNodeDTO(parentId, "Immobilie", "teal", 0, 0, List.of(child), null);
|
||||
when(tagService.getTagTree()).thenReturn(List.of(parent));
|
||||
|
||||
mockMvc.perform(get("/api/tags/tree"))
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package org.raddatz.familienarchiv.tag;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentSpecifications;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
|
||||
/**
|
||||
* Real-Postgres validation of the subtree document-count rollup ({@link TagRepository
|
||||
* #findSubtreeDocumentCountsPerTag}). The recursive CTE + COUNT(DISTINCT) cannot be exercised on
|
||||
* H2, so these run against {@code postgres:16-alpine} via Testcontainers. Covers issue #698
|
||||
* AC#1–#4, #6 (REQ-THEMEN-06 cycle guard) and #7 (count↔destination parity).
|
||||
*/
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class TagRollupRepositoryIntegrationTest {
|
||||
|
||||
@Autowired private TagRepository tagRepository;
|
||||
@Autowired private DocumentRepository documentRepository;
|
||||
@Autowired private EntityManager entityManager;
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private Tag tag(String name, UUID parentId) {
|
||||
return tagRepository.save(Tag.builder().name(name).parentId(parentId).build());
|
||||
}
|
||||
|
||||
private Document docWithTags(String title, Tag... tags) {
|
||||
return documentRepository.save(Document.builder()
|
||||
.title(title)
|
||||
.originalFilename(title + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.tags(new HashSet<>(Set.of(tags)))
|
||||
.build());
|
||||
}
|
||||
|
||||
private Map<UUID, Long> rollup() {
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
return tagRepository.findSubtreeDocumentCountsPerTag().stream()
|
||||
.collect(Collectors.toMap(TagRepository.TagCount::getTagId, TagRepository.TagCount::getCount));
|
||||
}
|
||||
|
||||
// ─── AC#4 — rollup of a leaf equals its direct count ────────────────────────
|
||||
|
||||
@Test
|
||||
void leafTag_subtreeCount_equalsItsDirectCount() {
|
||||
Tag leaf = tag("Tagebuch", null);
|
||||
docWithTags("a", leaf);
|
||||
docWithTags("b", leaf);
|
||||
docWithTags("c", leaf);
|
||||
|
||||
assertThat(rollup().get(leaf.getId())).isEqualTo(3L);
|
||||
}
|
||||
|
||||
// ─── AC#1 + AC#2 — parent rolls up children, distinct (shared doc counted once) ──
|
||||
|
||||
@Test
|
||||
void parentTag_rollsUpChildDocuments_countingSharedDocumentOnce() {
|
||||
Tag reisen = tag("Reisen", null);
|
||||
Tag italien = tag("Italien", reisen.getId());
|
||||
|
||||
Document shared = docWithTags("shared", reisen, italien); // tagged with both
|
||||
docWithTags("reisenOnly", reisen);
|
||||
docWithTags("it1", italien);
|
||||
docWithTags("it2", italien);
|
||||
docWithTags("it3", italien);
|
||||
docWithTags("it4", italien);
|
||||
|
||||
Map<UUID, Long> rollup = rollup();
|
||||
|
||||
// Reisen direct {shared, reisenOnly} = 2; Italien {shared, it1..it4} = 5; union distinct = 6
|
||||
assertThat(rollup.get(reisen.getId())).isEqualTo(6L);
|
||||
assertThat(rollup.get(italien.getId())).isEqualTo(5L);
|
||||
assertThat(shared.getId()).isNotNull();
|
||||
}
|
||||
|
||||
// ─── AC#3 — full descendant depth (grandchildren included) ──────────────────
|
||||
|
||||
@Test
|
||||
void rollup_includesGrandchildDocuments_atFullDepth() {
|
||||
Tag reisen = tag("Reisen", null);
|
||||
Tag italien = tag("Italien", reisen.getId());
|
||||
Tag rom = tag("Rom", italien.getId());
|
||||
|
||||
docWithTags("r1", reisen);
|
||||
docWithTags("i1", italien);
|
||||
docWithTags("rom1", rom);
|
||||
docWithTags("rom2", rom);
|
||||
docWithTags("rom3", rom);
|
||||
|
||||
Map<UUID, Long> rollup = rollup();
|
||||
|
||||
assertThat(rollup.get(reisen.getId())).isEqualTo(5L); // 1 + 1 + 3, all distinct
|
||||
assertThat(rollup.get(italien.getId())).isEqualTo(4L); // 1 + 3
|
||||
assertThat(rollup.get(rom.getId())).isEqualTo(3L);
|
||||
}
|
||||
|
||||
// ─── REQ-THEMEN-05 — a tag whose whole subtree is empty is absent (→ 0) ─────
|
||||
|
||||
@Test
|
||||
void tagWithEmptySubtree_isAbsentFromRollup() {
|
||||
Tag empty = tag("Leer", null);
|
||||
Tag emptyChild = tag("LeerKind", empty.getId());
|
||||
|
||||
Map<UUID, Long> rollup = rollup();
|
||||
|
||||
assertThat(rollup).doesNotContainKey(empty.getId());
|
||||
assertThat(rollup).doesNotContainKey(emptyChild.getId());
|
||||
}
|
||||
|
||||
// ─── REQ-THEMEN-06 — a hierarchy cycle terminates safely via the depth guard ──
|
||||
|
||||
@Test
|
||||
void rollup_terminatesSafely_whenHierarchyContainsCycle() {
|
||||
Tag a = tag("CycleA", null);
|
||||
Tag b = tag("CycleB", a.getId());
|
||||
// Close the loop: A.parent = B (DB only forbids parent_id == id, so a 2-node cycle is insertable)
|
||||
a.setParentId(b.getId());
|
||||
tagRepository.save(a);
|
||||
|
||||
docWithTags("ca", a);
|
||||
docWithTags("cb", b);
|
||||
|
||||
assertThatCode(this::rollup).doesNotThrowAnyException(); // depth guard prevents a runaway recursion
|
||||
Map<UUID, Long> rollup = rollup();
|
||||
|
||||
// COUNT(DISTINCT document_id) dedupes documents reached via repeated cycle paths
|
||||
assertThat(rollup.get(a.getId())).isEqualTo(2L);
|
||||
assertThat(rollup.get(b.getId())).isEqualTo(2L);
|
||||
}
|
||||
|
||||
// ─── AC#7 — count↔destination parity with the real search expansion ─────────
|
||||
|
||||
@Test
|
||||
void subtreeCount_equalsDistinctDocumentsFoundByTagSearch_parity() {
|
||||
// Uniquely-named root so name-based search expansion lines up with the per-id rollup.
|
||||
Tag root = tag("ZzzParitaetReise", null);
|
||||
Tag child = tag("ZzzParitaetItalien", root.getId());
|
||||
Tag grandchild = tag("ZzzParitaetRom", child.getId());
|
||||
|
||||
docWithTags("p_shared", root, child); // overlap inside the subtree
|
||||
docWithTags("p_root", root);
|
||||
docWithTags("p_child", child);
|
||||
docWithTags("p_gc1", grandchild);
|
||||
docWithTags("p_gc2", grandchild);
|
||||
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
long rollupCount = rollup().get(root.getId());
|
||||
|
||||
List<UUID> searchExpansionIds = tagRepository.findDescendantIdsByName("ZzzParitaetReise");
|
||||
var spec = DocumentSpecifications.hasTags(List.of(new HashSet<>(searchExpansionIds)), true);
|
||||
long distinctSearchResults = documentRepository.findAll(spec).stream()
|
||||
.map(Document::getId).distinct().count();
|
||||
|
||||
assertThat(rollupCount).isEqualTo(distinctSearchResults);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user