Compare commits
333 Commits
29eaa253a6
...
feature/66
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929acf6964 | ||
|
|
362672cdbf | ||
|
|
1e3e420860 | ||
|
|
3a758393bf | ||
|
|
1a0be4130e | ||
|
|
98f8c0129a | ||
|
|
79e9cc5a2b | ||
|
|
300b236d7d | ||
|
|
6c3552dc6a | ||
|
|
9d859dcb05 | ||
|
|
888adcb185 | ||
|
|
67272178a9 | ||
|
|
529c92fcc3 | ||
|
|
ec357ac13c | ||
|
|
a24764e58a | ||
|
|
09b810afb6 | ||
|
|
4bc96c3772 | ||
|
|
f99673321c | ||
|
|
728078f1e5 | ||
|
|
38f065bc60 | ||
|
|
6cc622b4db | ||
|
|
4169373693 | ||
|
|
8ed5b1e9e3 | ||
|
|
b1b8fa4bed | ||
|
|
2bd5c82826 | ||
|
|
7245571ea8 | ||
|
|
b56b9dfa74 | ||
|
|
6538c9e59a | ||
|
|
c816934391 | ||
|
|
1caae38946 | ||
|
|
f2a74a6064 | ||
|
|
e4a154406e | ||
|
|
151d6aa03f | ||
|
|
fc53e777d5 | ||
|
|
4fa2b83c0d | ||
|
|
e9ddaed76a | ||
|
|
5f53c3670f | ||
|
|
7ebf7acd72 | ||
|
|
2f7ea37466 | ||
|
|
5cf8fd149e | ||
|
|
21c85ff081 | ||
|
|
9cc682cf72 | ||
|
|
459ba14207 | ||
|
|
c56ba6219c | ||
|
|
cbf1984430 | ||
|
|
f6bfb8f030 | ||
|
|
bcd928f12d | ||
|
|
3501382ff5 | ||
|
|
05dd824283 | ||
|
|
aa6de48a71 | ||
|
|
d8588f4b72 | ||
|
|
f6bf7b9f5e | ||
|
|
b959e312b1 | ||
|
|
ae674b14d4 | ||
|
|
c9fb14fd49 | ||
|
|
d959cb54f1 | ||
|
|
6f5ca47543 | ||
|
|
c27c83f58c | ||
|
|
0f07a95bfe | ||
|
|
662927f928 | ||
|
|
0398ebea2c | ||
|
|
99d8229858 | ||
|
|
fee3c7e27d | ||
|
|
fa3f4167e9 | ||
|
|
a2b77e5bfa | ||
|
|
e95c678271 | ||
|
|
b9f06f6c21 | ||
|
|
1136294c1f | ||
|
|
9238cba06a | ||
|
|
2e59c0ef5b | ||
|
|
309436b9a4 | ||
|
|
e326630318 | ||
|
|
34c40cb0ee | ||
|
|
ace41ad209 | ||
|
|
6f55489ec2 | ||
|
|
fa4b6b5fc2 | ||
|
|
1f2351e3c0 | ||
|
|
7012234e6a | ||
|
|
306f3b6fe6 | ||
|
|
47a0770758 | ||
|
|
889d301f16 | ||
|
|
443c7a48db | ||
|
|
9ae1196d1c | ||
|
|
b37fd1728b | ||
|
|
6103d5d229 | ||
|
|
7b483d357a | ||
|
|
94a40237f4 | ||
|
|
5efe3b8a7c | ||
|
|
0f1f9055c3 | ||
|
|
8cac63e938 | ||
|
|
97db718f81 | ||
|
|
06127724de | ||
|
|
7c017eca2a | ||
|
|
97ab9e38df | ||
|
|
f10b80a03f | ||
|
|
6478cc58ae | ||
|
|
a7c45b3a0e | ||
|
|
5ff0c25e10 | ||
|
|
7ba3a29592 | ||
|
|
d314fd9338 | ||
|
|
18d5a1e2da | ||
|
|
df00ea4238 | ||
|
|
ff1a7c07f1 | ||
|
|
366b484815 | ||
|
|
88c8063227 | ||
|
|
3066d3d3ff | ||
|
|
3e7ddea90a | ||
|
|
75b3ca8b9e | ||
|
|
74c4c390fc | ||
|
|
29087319e6 | ||
|
|
53457d9319 | ||
|
|
2d97595e9c | ||
|
|
a177077b40 | ||
|
|
b7a2332861 | ||
|
|
1da1a8d223 | ||
|
|
59715bdccd | ||
|
|
53a661adb6 | ||
|
|
4942c0ea07 | ||
|
|
7edc002ebb | ||
|
|
b43dd6cdd4 | ||
|
|
cff486dda7 | ||
|
|
df14e6b1ee | ||
|
|
1908dde859 | ||
|
|
4845e7a3c1 | ||
|
|
c6cceec6e9 | ||
|
|
8f6f4f2d62 | ||
|
|
6f7aa643c9 | ||
|
|
adfff420a5 | ||
|
|
8e9e3bba06 | ||
|
|
627fc44d99 | ||
|
|
6583226d79 | ||
|
|
41b205becc | ||
|
|
f22dcaecb7 | ||
|
|
1109ab917b | ||
|
|
769984608b | ||
|
|
c282f38170 | ||
|
|
3ea7f0b5b2 | ||
|
|
bcba4dab80 | ||
|
|
a4a3e3b105 | ||
|
|
cac00ed711 | ||
|
|
637829cebc | ||
|
|
4e636b3253 | ||
|
|
ab2708e63b | ||
|
|
ed8e9576e4 | ||
|
|
0958df7768 | ||
|
|
f4ffd8acee | ||
|
|
0801da8df0 | ||
|
|
e0e1578bdd | ||
|
|
2df71beb7e | ||
|
|
2dbb3c37b4 | ||
|
|
67368b4413 | ||
|
|
ddf6cf4cbc | ||
|
|
df952861c4 | ||
|
|
22a5ee816a | ||
|
|
0179e93a4b | ||
|
|
0fc0cbcffd | ||
|
|
549cb15845 | ||
|
|
74ddf16b01 | ||
|
|
ebaedb1af0 | ||
|
|
e75ac8ec45 | ||
|
|
525f091b3a | ||
|
|
d6abf990c7 | ||
|
|
77d59c5d83 | ||
|
|
6c2b9af10b | ||
|
|
2e3744d9ef | ||
|
|
131ed336bc | ||
|
|
3fa3460dbf | ||
|
|
79edb94558 | ||
|
|
52d8dc2b20 | ||
|
|
696b71da5a | ||
|
|
f3e3545d06 | ||
|
|
4bb6685edb | ||
|
|
18c93d4eaa | ||
|
|
eca4f1f0e8 | ||
|
|
4e33f52add | ||
|
|
890f014bb3 | ||
|
|
429ff32eda | ||
|
|
38a4ca2e34 | ||
|
|
b63a2040e3 | ||
|
|
0c4b22291f | ||
|
|
f1a61278f9 | ||
|
|
2914010b68 | ||
|
|
1a7e4ce536 | ||
|
|
3fa0f59529 | ||
|
|
36d50222ec | ||
|
|
d47326d01c | ||
|
|
0af43043ba | ||
|
|
51f7efe333 | ||
|
|
8f0fb89e22 | ||
|
|
9d812572c8 | ||
|
|
4ee36b2047 | ||
|
|
1253e89887 | ||
|
|
197a3e71d5 | ||
|
|
4f469db02e | ||
|
|
9886f2bcac | ||
|
|
006d02a137 | ||
|
|
c89441278f | ||
|
|
5301820a88 | ||
|
|
feb5275a94 | ||
|
|
4037564e65 | ||
|
|
0ef50d0ae1 | ||
|
|
9579391e27 | ||
|
|
720615bb1a | ||
|
|
6fbec80414 | ||
|
|
12416e7704 | ||
|
|
d56e6eadab | ||
|
|
510e406a5e | ||
|
|
711d170607 | ||
|
|
55617722f6 | ||
|
|
47afb9e181 | ||
|
|
db951d80cf | ||
|
|
a47027d67a | ||
|
|
1c94a43cb5 | ||
|
|
a1fc7b13d9 | ||
|
|
033d430688 | ||
|
|
640bdc12db | ||
|
|
93e58be141 | ||
|
|
96e8a07a8c | ||
|
|
f46ae2658f | ||
|
|
6125f50d6d | ||
|
|
197c948a35 | ||
|
|
4a4248e726 | ||
|
|
8210984fe3 | ||
|
|
e1e6d2d4b2 | ||
|
|
5ad5f82864 | ||
|
|
19e2f65a21 | ||
|
|
909f960b2e | ||
|
|
7b282f699d | ||
|
|
392097287c | ||
|
|
728f9cd1b0 | ||
|
|
35fbaf8154 | ||
|
|
978a2b3cdb | ||
|
|
30efb54aac | ||
|
|
dbf74cb91a | ||
|
|
261cbbd867 | ||
|
|
6f862243fd | ||
|
|
3d3c111c2b | ||
|
|
cdd5bfa318 | ||
|
|
85c13b3d46 | ||
|
|
9a460b3c90 | ||
|
|
cdc3e2e4c8 | ||
|
|
e89a90ff66 | ||
|
|
0c0a4830cd | ||
|
|
dd843d76c2 | ||
|
|
9601974db0 | ||
|
|
1782526c99 | ||
|
|
76ef54e064 | ||
|
|
f1d1ac3f1a | ||
|
|
0f48ffede5 | ||
|
|
3e72157ee1 | ||
|
|
e2d3975524 | ||
|
|
59e99f862a | ||
|
|
bb39ca59ec | ||
|
|
6b53cbfc5b | ||
|
|
e3e8373526 | ||
|
|
907a6a6b53 | ||
|
|
f27e2d33a5 | ||
|
|
6832300a4b | ||
|
|
9c5267e1f0 | ||
|
|
4979ae1867 | ||
|
|
29ef82f7b4 | ||
|
|
f458c11a0d | ||
|
|
e615ba1bbf | ||
|
|
1bec7dd17e | ||
|
|
a0339a5526 | ||
|
|
65cae4a5e8 | ||
|
|
c8cc0646cb | ||
|
|
e8057fe517 | ||
|
|
378023c53d | ||
|
|
ff3e863032 | ||
|
|
8fc32f18ce | ||
|
|
0cd9ea915e | ||
|
|
f0e7f73ec1 | ||
|
|
567f9267e8 | ||
|
|
1dc5bf4377 | ||
|
|
31d3ec8367 | ||
|
|
d739f58bb5 | ||
|
|
18e675a5b2 | ||
|
|
a3fc838855 | ||
|
|
d5043053e0 | ||
|
|
c932dd19d9 | ||
|
|
c532ad21bf | ||
|
|
0e95bd9160 | ||
|
|
e312cce4e1 | ||
|
|
5587722800 | ||
|
|
0451b6630c | ||
|
|
f77fb79cd2 | ||
|
|
1247b51d9e | ||
|
|
7342c60952 | ||
|
|
328bd2c3b4 | ||
|
|
db87a214fd | ||
|
|
ad95b09046 | ||
|
|
1e95ca979b | ||
|
|
1cae9ac311 | ||
|
|
72bd2e11b4 | ||
|
|
69b3c663c0 | ||
|
|
f470a39ad2 | ||
|
|
e2f287d3d8 | ||
|
|
914e438793 | ||
|
|
6266c5f721 | ||
|
|
f564c30ae2 | ||
|
|
a5ce46359a | ||
|
|
b45953e567 | ||
|
|
36d1b9c038 | ||
|
|
56bcbcdd5c | ||
|
|
9b9bfde843 | ||
|
|
164a917d95 | ||
|
|
96c0aa592c | ||
|
|
d8520d9714 | ||
|
|
873d668653 | ||
|
|
4e257a7ca4 | ||
|
|
d0bb6729cd | ||
|
|
32ede3e3ce | ||
|
|
5da78e5e30 | ||
|
|
cb108faaf8 | ||
|
|
611b82ccde | ||
|
|
64d8f9d904 | ||
|
|
6f452a9a8b | ||
|
|
20fe5637c1 | ||
|
|
9bf8cf831d | ||
|
|
9f4a1141ef | ||
|
|
cb818f4bfa | ||
|
|
9c195ff5cb | ||
|
|
54d32c9163 | ||
|
|
0b5ab73963 | ||
|
|
956387471d | ||
|
|
78fd9e026e | ||
|
|
4d6fb06e02 | ||
|
|
8944f8bb44 | ||
|
|
1b178767ab | ||
|
|
7d10653c41 | ||
|
|
b7a03614bc | ||
|
|
49c5324352 |
@@ -39,6 +39,12 @@ PORT_PROMETHEUS=9090
|
|||||||
# Grafana admin password — change this before exposing Grafana beyond localhost
|
# Grafana admin password — change this before exposing Grafana beyond localhost
|
||||||
GRAFANA_ADMIN_PASSWORD=changeme
|
GRAFANA_ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# Password for the read-only grafana_reader PostgreSQL role used by the PO
|
||||||
|
# Overview dashboard. Consumed by Flyway V68 (to set the role's password) and
|
||||||
|
# by Grafana's PostgreSQL datasource (to connect). REQUIRED in production —
|
||||||
|
# generate with: openssl rand -hex 32
|
||||||
|
GRAFANA_DB_PASSWORD=changeme-generate-with-openssl-rand-hex-32
|
||||||
|
|
||||||
# GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
|
# GlitchTip domain — production: use https://glitchtip.archiv.raddatz.cloud (must match Caddy vhost)
|
||||||
GLITCHTIP_DOMAIN=http://localhost:3002
|
GLITCHTIP_DOMAIN=http://localhost:3002
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
name: Unit & Component Tests
|
name: Unit & Component Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
image: mcr.microsoft.com/playwright:v1.60.0-noble
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -29,6 +29,10 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Security audit (no dev deps)
|
||||||
|
run: npm audit --audit-level=high --omit=dev
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Compile Paraglide i18n
|
- name: Compile Paraglide i18n
|
||||||
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -61,6 +65,29 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Assert no raw document date rendered via {@html} (CWE-79 — #666)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# meta_date_raw is untrusted verbatim spreadsheet text — it must render via
|
||||||
|
# Svelte default escaping, never {@html}. This guard flags any {@html ...}
|
||||||
|
# whose expression references a raw-date variable. A comment mentioning
|
||||||
|
# "{@html}" without a raw token inside the braces does NOT match.
|
||||||
|
# The token list MUST cover every variable that carries the raw value:
|
||||||
|
# DocumentDate.svelte exposes it via the `raw` prop, so `\braw\b` is included.
|
||||||
|
# Grow this list whenever a new raw-bearing variable name is introduced.
|
||||||
|
pattern='\{@html[^}]*(metaDateRaw|documentDateRaw|rawDate|\braw\b)'
|
||||||
|
# Self-test: the regex must catch the dangerous forms and ignore the comment form.
|
||||||
|
printf '{@html doc.metaDateRaw}\n' | grep -qP "$pattern" \
|
||||||
|
|| { echo "FAIL: guard self-test — regex missed the unsafe {@html metaDateRaw} form"; exit 1; }
|
||||||
|
printf '{@html raw}\n' | grep -qP "$pattern" \
|
||||||
|
|| { echo "FAIL: guard self-test — regex missed the unsafe {@html raw} form (DocumentDate prop)"; exit 1; }
|
||||||
|
printf 'never use {@html} for this\n' | grep -qvP "$pattern" \
|
||||||
|
|| { echo "FAIL: guard self-test — regex wrongly flagged a {@html} comment"; exit 1; }
|
||||||
|
if grep -rPln "$pattern" --include='*.svelte' frontend/src/; then
|
||||||
|
echo "FAIL: meta_date_raw rendered via {@html} — use default {…} escaping (CWE-79, #666)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Assert no (upload|download)-artifact past v3
|
- name: Assert no (upload|download)-artifact past v3
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ name: nightly
|
|||||||
# STAGING_APP_ADMIN_USERNAME
|
# STAGING_APP_ADMIN_USERNAME
|
||||||
# STAGING_APP_ADMIN_PASSWORD
|
# STAGING_APP_ADMIN_PASSWORD
|
||||||
# GRAFANA_ADMIN_PASSWORD
|
# GRAFANA_ADMIN_PASSWORD
|
||||||
|
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
|
||||||
# GLITCHTIP_SECRET_KEY
|
# GLITCHTIP_SECRET_KEY
|
||||||
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
||||||
|
|
||||||
@@ -79,6 +80,8 @@ jobs:
|
|||||||
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
|
IMPORT_HOST_DIR=/srv/familienarchiv-staging/import
|
||||||
POSTGRES_USER=archiv
|
POSTGRES_USER=archiv
|
||||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||||
|
VITE_SENTRY_DSN=${{ secrets.VITE_SENTRY_DSN }}
|
||||||
|
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Verify backend /import:ro mount is wired
|
- name: Verify backend /import:ro mount is wired
|
||||||
@@ -142,6 +145,7 @@ jobs:
|
|||||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||||
|
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||||
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD=${{ secrets.STAGING_POSTGRES_PASSWORD }}
|
||||||
POSTGRES_HOST=archiv-staging-db-1
|
POSTGRES_HOST=archiv-staging-db-1
|
||||||
@@ -252,20 +256,20 @@ jobs:
|
|||||||
URL="https://$HOST"
|
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)
|
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; }
|
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||||
RESOLVE="--resolve $HOST:443:$HOST_IP"
|
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||||
curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
|
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||||
# fail this check rather than pass it silently.
|
# fail this check rather than pass it silently.
|
||||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||||
# Permissions-Policy denies APIs the app does not use (camera,
|
# Permissions-Policy denies APIs the app does not use (camera,
|
||||||
# microphone, geolocation). A regression that loosens or drops the
|
# microphone, geolocation). A regression that loosens or drops the
|
||||||
# header now fails the smoke step.
|
# header now fails the smoke step.
|
||||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||||
status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||||
echo "All smoke checks passed"
|
echo "All smoke checks passed"
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ name: release
|
|||||||
# MAIL_USERNAME
|
# MAIL_USERNAME
|
||||||
# MAIL_PASSWORD
|
# MAIL_PASSWORD
|
||||||
# GRAFANA_ADMIN_PASSWORD
|
# GRAFANA_ADMIN_PASSWORD
|
||||||
|
# GRAFANA_DB_PASSWORD (read-only grafana_reader DB role, issue #651)
|
||||||
# GLITCHTIP_SECRET_KEY
|
# GLITCHTIP_SECRET_KEY
|
||||||
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
# SENTRY_DSN (set after GlitchTip first-run; empty = Sentry disabled)
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ jobs:
|
|||||||
IMPORT_HOST_DIR=/srv/familienarchiv-production/import
|
IMPORT_HOST_DIR=/srv/familienarchiv-production/import
|
||||||
POSTGRES_USER=archiv
|
POSTGRES_USER=archiv
|
||||||
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN=${{ secrets.SENTRY_DSN }}
|
||||||
|
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build images
|
- name: Build images
|
||||||
@@ -110,6 +112,7 @@ jobs:
|
|||||||
cp docker-compose.observability.yml /opt/familienarchiv/
|
cp docker-compose.observability.yml /opt/familienarchiv/
|
||||||
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
cat > /opt/familienarchiv/obs-secrets.env <<'EOF'
|
||||||
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }}
|
||||||
|
GRAFANA_DB_PASSWORD=${{ secrets.GRAFANA_DB_PASSWORD }}
|
||||||
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
GLITCHTIP_SECRET_KEY=${{ secrets.GLITCHTIP_SECRET_KEY }}
|
||||||
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD=${{ secrets.PROD_POSTGRES_PASSWORD }}
|
||||||
POSTGRES_HOST=archiv-production-db-1
|
POSTGRES_HOST=archiv-production-db-1
|
||||||
@@ -181,28 +184,31 @@ jobs:
|
|||||||
|
|
||||||
- name: Smoke test deployed environment
|
- name: Smoke test deployed environment
|
||||||
# See nightly.yml — same three checks, against the prod vhost.
|
# See nightly.yml — same three checks, against the prod vhost.
|
||||||
# --resolve pins to the bridge gateway IP (the host), not 127.0.0.1
|
# --resolve stored as a Bash array so "${RESOLVE[@]}" expands to two
|
||||||
# — see nightly.yml for the full network topology explanation.
|
# 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: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
HOST="archiv.raddatz.cloud"
|
HOST="archiv.raddatz.cloud"
|
||||||
URL="https://$HOST"
|
URL="https://$HOST"
|
||||||
HOST_IP=$(ip route show default | awk '/default/ {print $3}')
|
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 'ip route'"; exit 1; }
|
[ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via /proc/net/route"; exit 1; }
|
||||||
RESOLVE="--resolve $HOST:443:$HOST_IP"
|
RESOLVE=(--resolve "$HOST:443:$HOST_IP")
|
||||||
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)"
|
||||||
curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null
|
curl -fsS "${RESOLVE[@]}" --max-time 10 "$URL/login" -o /dev/null
|
||||||
# Pin the preload-list-eligible HSTS value, not just header presence:
|
# Pin the preload-list-eligible HSTS value, not just header presence:
|
||||||
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
# a degraded `max-age=1` or a dropped `includeSubDomains; preload` must
|
||||||
# fail this check rather than pass it silently.
|
# fail this check rather than pass it silently.
|
||||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
| grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload'
|
||||||
# Permissions-Policy denies APIs the app does not use (camera,
|
# Permissions-Policy denies APIs the app does not use (camera,
|
||||||
# microphone, geolocation). A regression that loosens or drops the
|
# microphone, geolocation). A regression that loosens or drops the
|
||||||
# header now fails the smoke step.
|
# header now fails the smoke step.
|
||||||
curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \
|
curl -fsS "${RESOLVE[@]}" --max-time 10 -I "$URL/" \
|
||||||
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
| grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)'
|
||||||
status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
status=$(curl -s "${RESOLVE[@]}" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health")
|
||||||
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
[ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; }
|
||||||
echo "All smoke checks passed"
|
echo "All smoke checks passed"
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,3 +26,7 @@ node_modules/
|
|||||||
|
|
||||||
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
|
# Repo uses npm; yarn.lock is ignored to avoid double-lockfile drift.
|
||||||
frontend/yarn.lock
|
frontend/yarn.lock
|
||||||
|
|
||||||
|
**/.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
11
CLAUDE.md
11
CLAUDE.md
@@ -77,7 +77,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
|
|||||||
```
|
```
|
||||||
backend/src/main/java/org/raddatz/familienarchiv/
|
backend/src/main/java/org/raddatz/familienarchiv/
|
||||||
├── audit/ Audit logging
|
├── audit/ Audit logging
|
||||||
├── auth/ AuthService, AuthSessionController, LoginRequest (Spring Session JDBC)
|
├── auth/ AuthService, AuthSessionController, LoginRequest, LoginRateLimiter, RateLimitProperties (Spring Session JDBC)
|
||||||
├── config/ Infrastructure config (Minio, Async, Web)
|
├── config/ Infrastructure config (Minio, Async, Web)
|
||||||
├── dashboard/ Dashboard analytics + StatsController/StatsService
|
├── dashboard/ Dashboard analytics + StatsController/StatsService
|
||||||
├── document/ Document domain (entities, controller, service, repository, DTOs)
|
├── document/ Document domain (entities, controller, service, repository, DTOs)
|
||||||
@@ -87,7 +87,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
|||||||
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
||||||
├── filestorage/ FileService (S3/MinIO)
|
├── filestorage/ FileService (S3/MinIO)
|
||||||
├── geschichte/ Geschichte (story) domain
|
├── geschichte/ Geschichte (story) domain
|
||||||
├── importing/ MassImportService
|
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
|
||||||
├── notification/ Notification domain + SseEmitterRegistry
|
├── notification/ Notification domain + SseEmitterRegistry
|
||||||
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
||||||
├── person/ Person domain
|
├── person/ Person domain
|
||||||
@@ -160,7 +160,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
|
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded).
|
||||||
|
|
||||||
### Security / Permissions
|
### Security / Permissions
|
||||||
|
|
||||||
@@ -192,7 +192,8 @@ frontend/src/routes/
|
|||||||
├── persons/
|
├── persons/
|
||||||
│ ├── [id]/ Person detail
|
│ ├── [id]/ Person detail
|
||||||
│ ├── [id]/edit/ Person edit form
|
│ ├── [id]/edit/ Person edit form
|
||||||
│ └── new/ Create person form
|
│ ├── new/ Create person form
|
||||||
|
│ └── review/ Triage view — confirm/rename/merge/delete provisional persons
|
||||||
├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
|
├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
|
||||||
├── aktivitaeten/ Unified activity feed (Chronik)
|
├── aktivitaeten/ Unified activity feed (Chronik)
|
||||||
├── geschichten/ Stories — list, [id], [id]/edit, new
|
├── geschichten/ Stories — list, [id], [id]/edit, new
|
||||||
@@ -267,7 +268,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
|||||||
|
|
||||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||||
|
|
||||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
|
**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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ if (!result.response.ok) {
|
|||||||
return { person: result.data! }; // non-null assertion is safe after the ok check
|
return { person: result.data! }; // non-null assertion is safe after the ok check
|
||||||
```
|
```
|
||||||
|
|
||||||
For multipart/form-data (file uploads): bypass the typed client and use raw `fetch` — the client cannot handle it.
|
For multipart/form-data (file uploads): bypass the typed client and use `event.fetch` directly — never global `fetch`. The typed client cannot handle multipart bodies, but `event.fetch` is still required so that `handleFetch` injects the session cookie.
|
||||||
|
|
||||||
### Date handling
|
### Date handling
|
||||||
|
|
||||||
@@ -272,6 +272,7 @@ For multipart/form-data (file uploads): bypass the typed client and use raw `fet
|
|||||||
| Form display | German `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()` |
|
| Form display | German `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()` |
|
||||||
| Wire format | ISO 8601 via a hidden `<input type="hidden" name="documentDate" value={dateIso}>` |
|
| Wire format | ISO 8601 via a hidden `<input type="hidden" name="documentDate" value={dateIso}>` |
|
||||||
| Display | `new Intl.DateTimeFormat('de-DE', …).format(new Date(val + 'T12:00:00'))` |
|
| Display | `new Intl.DateTimeFormat('de-DE', …).format(new Date(val + 'T12:00:00'))` |
|
||||||
|
| Honest precision display | `formatDocumentDate(iso, precision, end?, raw?, locale?)` (`$lib/shared/utils/documentDate.ts`) or the `<DocumentDate>` component — renders a document date at exactly its `meta_date_precision` (MONTH → "Juni 1916", never a fabricated day). It mirrors the Java `DocumentTitleFormatter`; both are pinned to `docs/date-label-fixtures.json` so the title and UI labels can't drift. `meta_date_raw` is untrusted — render it via default escaping, never `{@html}` (a CI guard enforces this). |
|
||||||
|
|
||||||
### Security checklist (new endpoint)
|
### Security checklist (new endpoint)
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ src/main/java/org/raddatz/familienarchiv/
|
|||||||
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
||||||
├── filestorage/ # FileService (S3/MinIO)
|
├── filestorage/ # FileService (S3/MinIO)
|
||||||
├── geschichte/ # Geschichte (story) domain
|
├── geschichte/ # Geschichte (story) domain
|
||||||
├── importing/ # MassImportService
|
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
|
||||||
├── notification/ # Notification domain + SseEmitterRegistry
|
├── notification/ # Notification domain + SseEmitterRegistry
|
||||||
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
||||||
├── person/ # Person domain — Person, PersonService, PersonController
|
├── person/ # Person domain — Person, PersonService, PersonController
|
||||||
@@ -97,7 +97,10 @@ public class MyEntity {
|
|||||||
|
|
||||||
- Annotated with `@Service`, `@RequiredArgsConstructor`, optionally `@Slf4j`.
|
- Annotated with `@Service`, `@RequiredArgsConstructor`, optionally `@Slf4j`.
|
||||||
- Write methods: `@Transactional`.
|
- Write methods: `@Transactional`.
|
||||||
- Read methods: no annotation (default non-transactional).
|
- Read methods: no annotation (default non-transactional) — **except** when the method returns
|
||||||
|
an entity whose lazy associations must remain accessible to the caller after the method
|
||||||
|
returns. In that case, use `@Transactional(readOnly = true)` to keep the Hibernate session
|
||||||
|
open. Removing this annotation causes `LazyInitializationException` in production. See ADR-022.
|
||||||
- Cross-domain access goes through the other domain's service, never its repository.
|
- Cross-domain access goes through the other domain's service, never its repository.
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|||||||
@@ -180,11 +180,16 @@
|
|||||||
<artifactId>flyway-database-postgresql</artifactId>
|
<artifactId>flyway-database-postgresql</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Caffeine cache for in-memory rate limiting -->
|
<!-- Caffeine cache + Bucket4j for in-memory rate limiting -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
<artifactId>caffeine</artifactId>
|
<artifactId>caffeine</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.bucket4j</groupId>
|
||||||
|
<artifactId>bucket4j-core</artifactId>
|
||||||
|
<version>8.10.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -43,8 +43,14 @@ public enum AuditKind {
|
|||||||
/** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */
|
/** Payload: {@code {"email": "addr", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} — password NEVER included */
|
||||||
LOGIN_FAILED,
|
LOGIN_FAILED,
|
||||||
|
|
||||||
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0..."}} */
|
/** Payload: {@code {"userId": "uuid", "ip": "1.2.3.4", "ua": "Mozilla/5.0...", "reason": "password_change|password_reset|admin_force_logout", "revokedCount": 3}} */
|
||||||
LOGOUT;
|
LOGOUT,
|
||||||
|
|
||||||
|
/** Payload: {@code {"actorId": "uuid", "targetUserId": "uuid", "revokedCount": 3}} */
|
||||||
|
ADMIN_FORCE_LOGOUT,
|
||||||
|
|
||||||
|
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||||
|
LOGIN_RATE_LIMITED;
|
||||||
|
|
||||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||||
|
|||||||
@@ -24,13 +24,18 @@ public class AuthService {
|
|||||||
private final AuthenticationManager authenticationManager;
|
private final AuthenticationManager authenticationManager;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final LoginRateLimiter loginRateLimiter;
|
||||||
|
private final SessionRevocationPort sessionRevocationPort;
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates credentials and returns the authenticated user plus the Spring Security
|
|
||||||
* Authentication object. The caller is responsible for persisting the Authentication
|
|
||||||
* to the session via SecurityContextRepository.
|
|
||||||
*/
|
|
||||||
public LoginResult login(String email, String password, String ip, String ua) {
|
public LoginResult login(String email, String password, String ip, String ua) {
|
||||||
|
try {
|
||||||
|
loginRateLimiter.checkAndConsume(ip, email);
|
||||||
|
} catch (DomainException ex) {
|
||||||
|
auditService.log(AuditKind.LOGIN_RATE_LIMITED, null, null, Map.of(
|
||||||
|
"ip", ip,
|
||||||
|
"email", email));
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
Authentication auth = authenticationManager.authenticate(
|
Authentication auth = authenticationManager.authenticate(
|
||||||
new UsernamePasswordAuthenticationToken(email, password));
|
new UsernamePasswordAuthenticationToken(email, password));
|
||||||
@@ -40,6 +45,7 @@ public class AuthService {
|
|||||||
"userId", user.getId().toString(),
|
"userId", user.getId().toString(),
|
||||||
"ip", ip,
|
"ip", ip,
|
||||||
"ua", truncateUa(ua)));
|
"ua", truncateUa(ua)));
|
||||||
|
loginRateLimiter.invalidateOnSuccess(ip, email);
|
||||||
return new LoginResult(user, auth);
|
return new LoginResult(user, auth);
|
||||||
} catch (AuthenticationException ex) {
|
} catch (AuthenticationException ex) {
|
||||||
// Audit login failure — intentionally does NOT log the attempted password.
|
// Audit login failure — intentionally does NOT log the attempted password.
|
||||||
@@ -53,6 +59,14 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||||
|
return sessionRevocationPort.revokeOtherSessions(currentSessionId, principalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int revokeAllSessions(String principalName) {
|
||||||
|
return sessionRevocationPort.revokeAllSessions(principalName);
|
||||||
|
}
|
||||||
|
|
||||||
public void logout(String email, String ip, String ua) {
|
public void logout(String email, String ip, String ua) {
|
||||||
AppUser user = userService.findByEmail(email);
|
AppUser user = userService.findByEmail(email);
|
||||||
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(
|
auditService.log(AuditKind.LOGOUT, user.getId(), null, Map.of(
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||||
|
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
class JdbcSessionRevocationAdapter implements SessionRevocationPort {
|
||||||
|
|
||||||
|
private final JdbcIndexedSessionRepository sessionRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||||
|
int count = 0;
|
||||||
|
for (String id : sessionRepository.findByPrincipalName(principalName).keySet()) {
|
||||||
|
if (!id.equals(currentSessionId)) {
|
||||||
|
sessionRepository.deleteById(id);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeAllSessions(String principalName) {
|
||||||
|
var sessions = sessionRepository.findByPrincipalName(principalName);
|
||||||
|
sessions.keySet().forEach(sessionRepository::deleteById);
|
||||||
|
return sessions.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import com.github.benmanes.caffeine.cache.LoadingCache;
|
||||||
|
import io.github.bucket4j.Bandwidth;
|
||||||
|
import io.github.bucket4j.Bucket;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class LoginRateLimiter {
|
||||||
|
|
||||||
|
private final LoadingCache<String, Bucket> byIpEmail;
|
||||||
|
private final LoadingCache<String, Bucket> byIp;
|
||||||
|
private final int maxPerIpEmail;
|
||||||
|
private final int maxPerIp;
|
||||||
|
private final int windowMinutes;
|
||||||
|
|
||||||
|
public LoginRateLimiter(RateLimitProperties props) {
|
||||||
|
this.maxPerIpEmail = props.getMaxAttemptsPerIpEmail();
|
||||||
|
this.maxPerIp = props.getMaxAttemptsPerIp();
|
||||||
|
this.windowMinutes = props.getWindowMinutes();
|
||||||
|
|
||||||
|
this.byIpEmail = Caffeine.newBuilder()
|
||||||
|
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
|
||||||
|
.build(key -> newBucket(maxPerIpEmail, windowMinutes));
|
||||||
|
|
||||||
|
this.byIp = Caffeine.newBuilder()
|
||||||
|
.expireAfterAccess(windowMinutes, TimeUnit.MINUTES)
|
||||||
|
.build(key -> newBucket(maxPerIp, windowMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: This cache is node-local (in-memory). In a multi-replica deployment,
|
||||||
|
// effective limits would be multiplied by replica count.
|
||||||
|
// For the current single-VPS setup this is the correct, simplest implementation.
|
||||||
|
|
||||||
|
public void checkAndConsume(String ip, String email) {
|
||||||
|
long retryAfterSeconds = windowMinutes * 60L;
|
||||||
|
String key = ip + ":" + email.toLowerCase(Locale.ROOT);
|
||||||
|
if (!byIpEmail.get(key).tryConsume(1)) {
|
||||||
|
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||||
|
"Too many login attempts from " + ip, retryAfterSeconds);
|
||||||
|
}
|
||||||
|
if (!byIp.get(ip).tryConsume(1)) {
|
||||||
|
// Refund the ipEmail token so IP-level blocking does not erode the per-email quota.
|
||||||
|
byIpEmail.get(key).addTokens(1);
|
||||||
|
throw DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS,
|
||||||
|
"Too many login attempts from " + ip, retryAfterSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void invalidateOnSuccess(String ip, String email) {
|
||||||
|
byIpEmail.invalidate(ip + ":" + email.toLowerCase(Locale.ROOT));
|
||||||
|
byIp.invalidate(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bucket newBucket(int limit, int minutes) {
|
||||||
|
return Bucket.builder()
|
||||||
|
.addLimit(Bandwidth.builder()
|
||||||
|
.capacity(limit)
|
||||||
|
.refillGreedy(limit, Duration.ofMinutes(minutes))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
class NoOpSessionRevocationAdapter implements SessionRevocationPort {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeOtherSessions(String currentSessionId, String principalName) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int revokeAllSessions(String principalName) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties("rate-limit.login")
|
||||||
|
@Data
|
||||||
|
public class RateLimitProperties {
|
||||||
|
private int maxAttemptsPerIpEmail = 10;
|
||||||
|
private int maxAttemptsPerIp = 20;
|
||||||
|
private int windowMinutes = 15;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class SessionRevocationConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SessionRevocationPort sessionRevocationPort(
|
||||||
|
@Autowired(required = false) JdbcIndexedSessionRepository sessionRepository) {
|
||||||
|
if (sessionRepository != null) {
|
||||||
|
return new JdbcSessionRevocationAdapter(sessionRepository);
|
||||||
|
}
|
||||||
|
return new NoOpSessionRevocationAdapter();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
public interface SessionRevocationPort {
|
||||||
|
int revokeOtherSessions(String currentSessionId, String principalName);
|
||||||
|
int revokeAllSessions(String principalName);
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.flywaydb.core.Flyway;
|
import org.flywaydb.core.Flyway;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -14,6 +16,7 @@ import javax.sql.DataSource;
|
|||||||
public class FlywayConfig {
|
public class FlywayConfig {
|
||||||
|
|
||||||
private final DataSource dataSource;
|
private final DataSource dataSource;
|
||||||
|
private final Environment environment;
|
||||||
|
|
||||||
@Bean(name = "flyway")
|
@Bean(name = "flyway")
|
||||||
public Flyway flyway() {
|
public Flyway flyway() {
|
||||||
@@ -21,6 +24,7 @@ public class FlywayConfig {
|
|||||||
Flyway flyway = Flyway.configure()
|
Flyway flyway = Flyway.configure()
|
||||||
.dataSource(dataSource)
|
.dataSource(dataSource)
|
||||||
.locations("classpath:db/migration")
|
.locations("classpath:db/migration")
|
||||||
|
.placeholders(Map.of("grafanaDbPassword", resolveGrafanaDbPassword()))
|
||||||
.baselineOnMigrate(true)
|
.baselineOnMigrate(true)
|
||||||
.baselineVersion("4")
|
.baselineVersion("4")
|
||||||
.load();
|
.load();
|
||||||
@@ -28,4 +32,22 @@ public class FlywayConfig {
|
|||||||
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
|
log.info("Flyway: {} migration(s) applied.", result.migrationsExecuted);
|
||||||
return flyway;
|
return flyway;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fail-closed: refuse to boot when GRAFANA_DB_PASSWORD is unset. The
|
||||||
|
// grafana_reader role's password is (re)set on every boot by
|
||||||
|
// R__grafana_reader_password.sql, so a missing env var means we'd either
|
||||||
|
// skip the rotation silently or — with a hardcoded fallback — publish a
|
||||||
|
// well-known credential for a role with SELECT on audit_log, documents,
|
||||||
|
// and transcription_blocks. Same shape as UserDataInitializer's refusal
|
||||||
|
// to seed default admin credentials outside dev/test/e2e.
|
||||||
|
String resolveGrafanaDbPassword() {
|
||||||
|
String value = environment.getProperty("GRAFANA_DB_PASSWORD");
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"GRAFANA_DB_PASSWORD is required: it is consumed by "
|
||||||
|
+ "R__grafana_reader_password.sql to (re)set the grafana_reader "
|
||||||
|
+ "role's password on every boot. Generate with: openssl rand -hex 32");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||||||
AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0));
|
AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0));
|
||||||
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
|
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
|
||||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||||
|
response.setHeader("Retry-After", "60");
|
||||||
response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}");
|
response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Precision of a document's date. Verbatim mirror of the import normalizer's
|
||||||
|
* {@code Precision} enum (tools/import-normalizer/dates.py) — the canonical output is the
|
||||||
|
* contract, so there is no translation layer. Do not add, remove, or rename values without
|
||||||
|
* also changing the normalizer; a mismatch silently breaks import idempotency (see ADR-025).
|
||||||
|
*/
|
||||||
|
public enum DatePrecision {
|
||||||
|
DAY,
|
||||||
|
MONTH,
|
||||||
|
SEASON,
|
||||||
|
YEAR,
|
||||||
|
RANGE,
|
||||||
|
APPROX,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.document;
|
|||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.BatchSize;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
@@ -21,6 +22,17 @@ import java.util.HashSet;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@NamedEntityGraph(name = "Document.full", attributeNodes = {
|
||||||
|
@NamedAttributeNode("sender"),
|
||||||
|
@NamedAttributeNode("receivers"),
|
||||||
|
@NamedAttributeNode("tags"),
|
||||||
|
@NamedAttributeNode("trainingLabels")
|
||||||
|
})
|
||||||
|
@NamedEntityGraph(name = "Document.list", attributeNodes = {
|
||||||
|
@NamedAttributeNode("sender"),
|
||||||
|
@NamedAttributeNode("receivers"),
|
||||||
|
@NamedAttributeNode("tags")
|
||||||
|
})
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "documents")
|
@Table(name = "documents")
|
||||||
@Data // Lombok: Generiert Getter, Setter, ToString, etc.
|
@Data // Lombok: Generiert Getter, Setter, ToString, etc.
|
||||||
@@ -79,6 +91,29 @@ public class Document {
|
|||||||
@Column(name = "meta_date")
|
@Column(name = "meta_date")
|
||||||
private LocalDate documentDate; // Wann wurde der Brief geschrieben?
|
private LocalDate documentDate; // Wann wurde der Brief geschrieben?
|
||||||
|
|
||||||
|
// Precision of documentDate — drives honest rendering ("ca. 1943", "Frühjahr 1943").
|
||||||
|
// Verbatim mirror of the normalizer's Precision enum (see ADR-025).
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "meta_date_precision", nullable = false, length = 16)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
@Builder.Default
|
||||||
|
private DatePrecision metaDatePrecision = DatePrecision.UNKNOWN;
|
||||||
|
|
||||||
|
// Range end — only set when metaDatePrecision is RANGE (open-ended ranges allowed → may be null).
|
||||||
|
@Column(name = "meta_date_end")
|
||||||
|
private LocalDate metaDateEnd;
|
||||||
|
|
||||||
|
// Original date cell, verbatim, preserved for provenance and "as written" display.
|
||||||
|
@Column(name = "meta_date_raw", columnDefinition = "TEXT")
|
||||||
|
private String metaDateRaw;
|
||||||
|
|
||||||
|
// Raw attribution preserved even when a person is linked via sender/receivers.
|
||||||
|
@Column(name = "sender_text", columnDefinition = "TEXT")
|
||||||
|
private String senderText;
|
||||||
|
|
||||||
|
@Column(name = "receiver_text", columnDefinition = "TEXT")
|
||||||
|
private String receiverText;
|
||||||
|
|
||||||
@Column(name = "meta_location")
|
@Column(name = "meta_location")
|
||||||
private String location;
|
private String location;
|
||||||
|
|
||||||
@@ -118,24 +153,27 @@ public class Document {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private ScriptType scriptType = ScriptType.UNKNOWN;
|
private ScriptType scriptType = ScriptType.UNKNOWN;
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.LAZY)
|
||||||
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
@JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id"))
|
||||||
|
@BatchSize(size = 50)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<Person> receivers = new HashSet<>();
|
private Set<Person> receivers = new HashSet<>();
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "sender_id")
|
@JoinColumn(name = "sender_id")
|
||||||
private Person sender;
|
private Person sender;
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.LAZY)
|
||||||
@JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
@JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id"))
|
||||||
|
@BatchSize(size = 50)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<Tag> tags = new HashSet<>();
|
private Set<Tag> tags = new HashSet<>();
|
||||||
|
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
@ElementCollection(fetch = FetchType.LAZY)
|
||||||
@CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id"))
|
@CollectionTable(name = "document_training_labels", joinColumns = @JoinColumn(name = "document_id"))
|
||||||
@Column(name = "label")
|
@Column(name = "label")
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
|
@BatchSize(size = 50)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
private Set<TrainingLabel> trainingLabels = new HashSet<>();
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ public class DocumentBatchMetadataDTO {
|
|||||||
private UUID senderId;
|
private UUID senderId;
|
||||||
private List<UUID> receiverIds;
|
private List<UUID> receiverIds;
|
||||||
private LocalDate documentDate;
|
private LocalDate documentDate;
|
||||||
|
private DatePrecision metaDatePrecision;
|
||||||
|
private LocalDate metaDateEnd;
|
||||||
private String location;
|
private String location;
|
||||||
private List<String> tagNames;
|
private List<String> tagNames;
|
||||||
private Boolean metadataComplete;
|
private Boolean metadataComplete;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record DocumentListItem(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
String title,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
String originalFilename,
|
||||||
|
String thumbnailUrl,
|
||||||
|
LocalDate documentDate,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
DatePrecision metaDatePrecision,
|
||||||
|
LocalDate metaDateEnd,
|
||||||
|
Person sender,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
List<Person> receivers,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
List<Tag> tags,
|
||||||
|
String archiveBox,
|
||||||
|
String archiveFolder,
|
||||||
|
String location,
|
||||||
|
String summary,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
int completionPercentage,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
List<ActivityActorDTO> contributors,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
SearchMatchData matchData
|
||||||
|
) {}
|
||||||
@@ -7,6 +7,8 @@ import org.raddatz.familienarchiv.document.DocumentStatus;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
import org.springframework.data.jpa.repository.EntityGraph;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
@@ -23,6 +25,18 @@ import java.util.UUID;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSpecificationExecutor<Document> {
|
public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSpecificationExecutor<Document> {
|
||||||
|
|
||||||
|
@EntityGraph("Document.full")
|
||||||
|
Optional<Document> findById(UUID id);
|
||||||
|
|
||||||
|
@EntityGraph("Document.list")
|
||||||
|
Page<Document> findAll(Specification<Document> spec, Pageable pageable);
|
||||||
|
|
||||||
|
@EntityGraph("Document.list")
|
||||||
|
List<Document> findAll(Specification<Document> spec);
|
||||||
|
|
||||||
|
@EntityGraph("Document.list")
|
||||||
|
Page<Document> findAll(Pageable pageable);
|
||||||
|
|
||||||
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
||||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||||
@@ -30,17 +44,21 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
|
// Wie oben, gibt aber nur das erste Ergebnis zurück — sicher wenn doppelte Dateinamen existieren
|
||||||
Optional<Document> findFirstByOriginalFilename(String originalFilename);
|
Optional<Document> findFirstByOriginalFilename(String originalFilename);
|
||||||
|
|
||||||
// Findet alle Dokumente mit einem bestimmten Status
|
// Callers access only status/id scalar fields — no graph needed.
|
||||||
// z.B. um alle offenen "PLACEHOLDER" zu finden
|
|
||||||
List<Document> findByStatus(DocumentStatus status);
|
List<Document> findByStatus(DocumentStatus status);
|
||||||
|
|
||||||
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
|
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
|
||||||
boolean existsByOriginalFilename(String originalFilename);
|
boolean existsByOriginalFilename(String originalFilename);
|
||||||
|
|
||||||
|
// lazy – @BatchSize(50) fallback active; see ADR-022
|
||||||
|
@EntityGraph("Document.full")
|
||||||
List<Document> findBySenderId(UUID senderId);
|
List<Document> findBySenderId(UUID senderId);
|
||||||
|
|
||||||
|
// lazy – @BatchSize(50) fallback active; see ADR-022
|
||||||
|
@EntityGraph("Document.full")
|
||||||
List<Document> findByReceiversId(UUID receiverId);
|
List<Document> findByReceiversId(UUID receiverId);
|
||||||
|
|
||||||
|
// Callers access only doc.getTags() to mutate the set — receivers/sender not touched; no graph needed.
|
||||||
List<Document> findByTags_Id(UUID tagId);
|
List<Document> findByTags_Id(UUID tagId);
|
||||||
|
|
||||||
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
||||||
@@ -55,12 +73,15 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
long countByMetadataCompleteFalse();
|
long countByMetadataCompleteFalse();
|
||||||
|
|
||||||
|
// No production callers — only used if a future export path iterates the full list; no graph needed.
|
||||||
List<Document> findByMetadataCompleteFalse(Sort sort);
|
List<Document> findByMetadataCompleteFalse(Sort sort);
|
||||||
|
|
||||||
|
// Callers map to IncompleteDocumentDTO using only scalar fields (id, title, createdAt) — no graph needed.
|
||||||
Page<Document> findByMetadataCompleteFalse(Pageable pageable);
|
Page<Document> findByMetadataCompleteFalse(Pageable pageable);
|
||||||
|
|
||||||
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
||||||
|
|
||||||
|
@EntityGraph("Document.full")
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"JOIN d.receivers r " +
|
"JOIN d.receivers r " +
|
||||||
"WHERE " +
|
"WHERE " +
|
||||||
@@ -75,6 +96,7 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@Param("to") LocalDate to,
|
@Param("to") LocalDate to,
|
||||||
Sort sort);
|
Sort sort);
|
||||||
|
|
||||||
|
@EntityGraph("Document.full")
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"LEFT JOIN d.receivers r " +
|
"LEFT JOIN d.receivers r " +
|
||||||
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
|
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.document;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public record DocumentSearchItem(
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
Document document,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
SearchMatchData matchData,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
int completionPercentage,
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
|
||||||
List<ActivityActorDTO> contributors
|
|
||||||
) {}
|
|
||||||
@@ -7,7 +7,7 @@ import java.util.List;
|
|||||||
|
|
||||||
public record DocumentSearchResult(
|
public record DocumentSearchResult(
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
List<DocumentSearchItem> items,
|
List<DocumentListItem> items,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
long totalElements,
|
long totalElements,
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@@ -21,16 +21,16 @@ public record DocumentSearchResult(
|
|||||||
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
* Single-page convenience factory used by empty-result shortcuts and by tests that
|
||||||
* don't care about paging. Treats the whole list as page 0 of itself.
|
* don't care about paging. Treats the whole list as page 0 of itself.
|
||||||
*/
|
*/
|
||||||
public static DocumentSearchResult of(List<DocumentSearchItem> items) {
|
public static DocumentSearchResult of(List<DocumentListItem> items) {
|
||||||
int size = items.size();
|
int size = items.size();
|
||||||
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paged factory used by the service when it has a real Pageable + full match count
|
* Paged factory used by the service when it has a real Pageable + full match count
|
||||||
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
|
||||||
*/
|
*/
|
||||||
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
|
public static DocumentSearchResult paged(List<DocumentListItem> slice, Pageable pageable, long totalElements) {
|
||||||
int pageSize = pageable.getPageSize();
|
int pageSize = pageable.getPageSize();
|
||||||
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||||
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import org.raddatz.familienarchiv.audit.AuditService;
|
|||||||
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
|
import org.raddatz.familienarchiv.document.DocumentBatchMetadataDTO;
|
||||||
import org.raddatz.familienarchiv.document.DocumentBatchSummary;
|
import org.raddatz.familienarchiv.document.DocumentBatchSummary;
|
||||||
import org.raddatz.familienarchiv.document.DocumentBulkEditDTO;
|
import org.raddatz.familienarchiv.document.DocumentBulkEditDTO;
|
||||||
import org.raddatz.familienarchiv.document.DocumentSearchItem;
|
|
||||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.document.DocumentSort;
|
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
|
||||||
@@ -379,6 +378,7 @@ public class DocumentService {
|
|||||||
// 1. Einfache Felder Update
|
// 1. Einfache Felder Update
|
||||||
doc.setTitle(dto.getTitle());
|
doc.setTitle(dto.getTitle());
|
||||||
doc.setDocumentDate(dto.getDocumentDate());
|
doc.setDocumentDate(dto.getDocumentDate());
|
||||||
|
applyDatePrecision(doc, dto);
|
||||||
doc.setLocation(dto.getLocation());
|
doc.setLocation(dto.getLocation());
|
||||||
doc.setTranscription(dto.getTranscription());
|
doc.setTranscription(dto.getTranscription());
|
||||||
doc.setSummary(dto.getSummary());
|
doc.setSummary(dto.getSummary());
|
||||||
@@ -447,6 +447,26 @@ public class DocumentService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the three date-precision fields only when the DTO carries them.
|
||||||
|
* A null field means "not submitted" — overwriting the stored value with null
|
||||||
|
* would fabricate a precision the user never chose, the exact dishonesty #666
|
||||||
|
* exists to prevent. A row with a genuinely-unknown precision must keep it when
|
||||||
|
* an unrelated edit (e.g. a location typo) is saved.
|
||||||
|
*/
|
||||||
|
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||||
|
if (dto.getMetaDatePrecision() != null) {
|
||||||
|
doc.setMetaDatePrecision(dto.getMetaDatePrecision());
|
||||||
|
}
|
||||||
|
if (dto.getMetaDateEnd() != null) {
|
||||||
|
doc.setMetaDateEnd(dto.getMetaDateEnd());
|
||||||
|
}
|
||||||
|
if (dto.getMetaDateRaw() != null) {
|
||||||
|
doc.setMetaDateRaw(dto.getMetaDateRaw());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||||
Document doc = documentRepository.findById(docId)
|
Document doc = documentRepository.findById(docId)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
|
||||||
@@ -635,7 +655,7 @@ public class DocumentService {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
|
@Transactional(readOnly = true)
|
||||||
public List<Document> getRecentActivity(int size) {
|
public List<Document> getRecentActivity(int size) {
|
||||||
return documentRepository.findAll(
|
return documentRepository.findAll(
|
||||||
PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
|
PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
|
||||||
@@ -735,7 +755,7 @@ public class DocumentService {
|
|||||||
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
|
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
|
private List<DocumentListItem> enrichItems(List<Document> documents, String text) {
|
||||||
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
List<Document> colorResolved = resolveDocumentTagColors(documents);
|
||||||
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
|
||||||
|
|
||||||
@@ -743,7 +763,7 @@ public class DocumentService {
|
|||||||
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
|
||||||
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
|
||||||
|
|
||||||
return colorResolved.stream().map(doc -> new DocumentSearchItem(
|
return colorResolved.stream().map(doc -> toListItem(
|
||||||
doc,
|
doc,
|
||||||
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
|
||||||
completionByDoc.getOrDefault(doc.getId(), 0),
|
completionByDoc.getOrDefault(doc.getId(), 0),
|
||||||
@@ -751,6 +771,28 @@ public class DocumentService {
|
|||||||
)).toList();
|
)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private DocumentListItem toListItem(Document doc, SearchMatchData match, int completionPct, List<ActivityActorDTO> contributors) {
|
||||||
|
return new DocumentListItem(
|
||||||
|
doc.getId(),
|
||||||
|
doc.getTitle(),
|
||||||
|
doc.getOriginalFilename(),
|
||||||
|
doc.getThumbnailUrl(),
|
||||||
|
doc.getDocumentDate(),
|
||||||
|
doc.getMetaDatePrecision(),
|
||||||
|
doc.getMetaDateEnd(),
|
||||||
|
doc.getSender(),
|
||||||
|
List.copyOf(doc.getReceivers()),
|
||||||
|
List.copyOf(doc.getTags()),
|
||||||
|
doc.getArchiveBox(),
|
||||||
|
doc.getArchiveFolder(),
|
||||||
|
doc.getLocation(),
|
||||||
|
doc.getSummary(),
|
||||||
|
completionPct,
|
||||||
|
contributors,
|
||||||
|
match
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {
|
||||||
return transcriptionBlockQueryService.getCompletionStats(docIds);
|
return transcriptionBlockQueryService.getCompletionStats(docIds);
|
||||||
}
|
}
|
||||||
@@ -843,6 +885,7 @@ public class DocumentService {
|
|||||||
documentRepository.save(doc);
|
documentRepository.save(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
public Document getDocumentById(UUID id) {
|
public Document getDocumentById(UUID id) {
|
||||||
Document doc = documentRepository.findById(id)
|
Document doc = documentRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import org.raddatz.familienarchiv.ocr.ScriptType;
|
|||||||
public class DocumentUpdateDTO {
|
public class DocumentUpdateDTO {
|
||||||
private String title;
|
private String title;
|
||||||
private LocalDate documentDate;
|
private LocalDate documentDate;
|
||||||
|
private DatePrecision metaDatePrecision;
|
||||||
|
private LocalDate metaDateEnd;
|
||||||
|
private String metaDateRaw;
|
||||||
|
private String senderText;
|
||||||
|
private String receiverText;
|
||||||
private String location;
|
private String location;
|
||||||
private String documentLocation;
|
private String documentLocation;
|
||||||
private String archiveBox;
|
private String archiveBox;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public class TranscriptionBlockController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public TranscriptionBlock createBlock(
|
public TranscriptionBlock createBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
@Valid @RequestBody CreateTranscriptionBlockDTO dto,
|
||||||
@@ -53,7 +53,7 @@ public class TranscriptionBlockController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{blockId}")
|
@PutMapping("/{blockId}")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public TranscriptionBlock updateBlock(
|
public TranscriptionBlock updateBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID blockId,
|
@PathVariable UUID blockId,
|
||||||
@@ -65,7 +65,7 @@ public class TranscriptionBlockController {
|
|||||||
|
|
||||||
@DeleteMapping("/{blockId}")
|
@DeleteMapping("/{blockId}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public void deleteBlock(
|
public void deleteBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID blockId) {
|
@PathVariable UUID blockId) {
|
||||||
@@ -73,7 +73,7 @@ public class TranscriptionBlockController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/reorder")
|
@PutMapping("/reorder")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public List<TranscriptionBlock> reorderBlocks(
|
public List<TranscriptionBlock> reorderBlocks(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@RequestBody ReorderTranscriptionBlocksDTO dto) {
|
@RequestBody ReorderTranscriptionBlocksDTO dto) {
|
||||||
@@ -82,7 +82,7 @@ public class TranscriptionBlockController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{blockId}/review")
|
@PutMapping("/{blockId}/review")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public TranscriptionBlock reviewBlock(
|
public TranscriptionBlock reviewBlock(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID blockId,
|
@PathVariable UUID blockId,
|
||||||
@@ -92,7 +92,7 @@ public class TranscriptionBlockController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/review-all")
|
@PutMapping("/review-all")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public List<TranscriptionBlock> markAllBlocksReviewed(
|
public List<TranscriptionBlock> markAllBlocksReviewed(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
|||||||
@@ -10,11 +10,21 @@ public class DomainException extends RuntimeException {
|
|||||||
|
|
||||||
private final ErrorCode code;
|
private final ErrorCode code;
|
||||||
private final HttpStatus status;
|
private final HttpStatus status;
|
||||||
|
/** Seconds until the rate-limit window resets; {@code null} when not applicable. */
|
||||||
|
private final Long retryAfterSeconds;
|
||||||
|
|
||||||
public DomainException(ErrorCode code, HttpStatus status, String developerMessage) {
|
public DomainException(ErrorCode code, HttpStatus status, String developerMessage) {
|
||||||
super(developerMessage);
|
super(developerMessage);
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.retryAfterSeconds = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DomainException(ErrorCode code, HttpStatus status, String developerMessage, Long retryAfterSeconds) {
|
||||||
|
super(developerMessage);
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
this.retryAfterSeconds = retryAfterSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ErrorCode getCode() {
|
public ErrorCode getCode() {
|
||||||
@@ -25,6 +35,11 @@ public class DomainException extends RuntimeException {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the {@code Retry-After} value in seconds, or {@code null} if not set. */
|
||||||
|
public Long getRetryAfterSeconds() {
|
||||||
|
return retryAfterSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Static factories for common cases ---
|
// --- Static factories for common cases ---
|
||||||
|
|
||||||
public static DomainException notFound(ErrorCode code, String message) {
|
public static DomainException notFound(ErrorCode code, String message) {
|
||||||
@@ -55,4 +70,12 @@ public class DomainException extends RuntimeException {
|
|||||||
public static DomainException internal(ErrorCode code, String message) {
|
public static DomainException internal(ErrorCode code, String message) {
|
||||||
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
|
return new DomainException(code, HttpStatus.INTERNAL_SERVER_ERROR, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static DomainException tooManyRequests(ErrorCode code, String message) {
|
||||||
|
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DomainException tooManyRequests(ErrorCode code, String message, long retryAfterSeconds) {
|
||||||
|
return new DomainException(code, HttpStatus.TOO_MANY_REQUESTS, message, retryAfterSeconds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ public enum ErrorCode {
|
|||||||
// --- Import ---
|
// --- Import ---
|
||||||
/** A mass import is already in progress; only one can run at a time. 409 */
|
/** A mass import is already in progress; only one can run at a time. 409 */
|
||||||
IMPORT_ALREADY_RUNNING,
|
IMPORT_ALREADY_RUNNING,
|
||||||
|
/** A canonical import artifact is missing, unreadable, or missing a required header. 400 */
|
||||||
|
IMPORT_ARTIFACT_INVALID,
|
||||||
|
|
||||||
// --- Thumbnails ---
|
// --- Thumbnails ---
|
||||||
/** A thumbnail backfill is already in progress; only one can run at a time. 409 */
|
/** A thumbnail backfill is already in progress; only one can run at a time. 409 */
|
||||||
@@ -68,6 +70,10 @@ public enum ErrorCode {
|
|||||||
SESSION_EXPIRED,
|
SESSION_EXPIRED,
|
||||||
/** The password-reset token is missing, expired, or already used. 400 */
|
/** The password-reset token is missing, expired, or already used. 400 */
|
||||||
INVALID_RESET_TOKEN,
|
INVALID_RESET_TOKEN,
|
||||||
|
/** CSRF token is missing or does not match the expected value. 403 */
|
||||||
|
CSRF_TOKEN_MISSING,
|
||||||
|
/** The login rate limit has been exceeded for this IP/email combination. 429 */
|
||||||
|
TOO_MANY_LOGIN_ATTEMPTS,
|
||||||
|
|
||||||
// --- Annotations ---
|
// --- Annotations ---
|
||||||
/** The annotation with the given ID does not exist. 404 */
|
/** The annotation with the given ID does not exist. 404 */
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ public class GlobalExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(DomainException.class)
|
@ExceptionHandler(DomainException.class)
|
||||||
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
|
public ResponseEntity<ErrorResponse> handleDomain(DomainException ex) {
|
||||||
return ResponseEntity
|
var builder = ResponseEntity.status(ex.getStatus());
|
||||||
.status(ex.getStatus())
|
if (ex.getRetryAfterSeconds() != null) {
|
||||||
.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
|
builder = builder.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()));
|
||||||
|
}
|
||||||
|
return builder.body(new ErrorResponse(ex.getCode(), ex.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the four canonical loaders in their real dependency order — encoded explicitly
|
||||||
|
* here, not implied by call order — and owns the async runner plus the {@link ImportStatus}
|
||||||
|
* state machine the admin UI consumes. The orchestrator smoke-checks that all four
|
||||||
|
* artifacts are present before starting, failing fast rather than half-loading tags but no
|
||||||
|
* documents. A malformed artifact (a loader throwing) sets {@code FAILED}; an individual
|
||||||
|
* bad file is surfaced through the {@link ImportStatus.SkippedFile} mechanism instead.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class CanonicalImportOrchestrator {
|
||||||
|
|
||||||
|
private static final String TAG_TREE_ARTIFACT = "canonical-tag-tree.xlsx";
|
||||||
|
private static final String PERSONS_ARTIFACT = "canonical-persons.xlsx";
|
||||||
|
private static final String PERSONS_TREE_ARTIFACT = "canonical-persons-tree.json";
|
||||||
|
private static final String DOCUMENTS_ARTIFACT = "canonical-documents.xlsx";
|
||||||
|
|
||||||
|
private final TagTreeImporter tagTreeImporter;
|
||||||
|
private final PersonRegisterImporter personRegisterImporter;
|
||||||
|
private final PersonTreeImporter personTreeImporter;
|
||||||
|
private final DocumentImporter documentImporter;
|
||||||
|
|
||||||
|
@Value("${app.import.dir:/import}")
|
||||||
|
private String canonicalDir;
|
||||||
|
|
||||||
|
private volatile ImportStatus currentStatus = new ImportStatus(
|
||||||
|
ImportStatus.State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, List.of(), null);
|
||||||
|
|
||||||
|
public ImportStatus getStatus() {
|
||||||
|
return currentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void runImportAsync() {
|
||||||
|
if (currentStatus.state() == ImportStatus.State.RUNNING) {
|
||||||
|
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
||||||
|
}
|
||||||
|
runImport();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronous entry point — wrapped by {@link #runImportAsync()} and called directly in tests. */
|
||||||
|
void runImport() {
|
||||||
|
currentStatus = new ImportStatus(ImportStatus.State.RUNNING, "IMPORT_RUNNING",
|
||||||
|
"Import läuft...", 0, List.of(), LocalDateTime.now());
|
||||||
|
try {
|
||||||
|
File tagTree = requireArtifact(TAG_TREE_ARTIFACT);
|
||||||
|
File persons = requireArtifact(PERSONS_ARTIFACT);
|
||||||
|
File personsTree = requireArtifact(PERSONS_TREE_ARTIFACT);
|
||||||
|
File documents = requireArtifact(DOCUMENTS_ARTIFACT);
|
||||||
|
|
||||||
|
// Dependency DAG: documents need persons + tags; the tree needs persons.
|
||||||
|
tagTreeImporter.load(tagTree);
|
||||||
|
personRegisterImporter.load(persons);
|
||||||
|
personTreeImporter.load(personsTree);
|
||||||
|
DocumentImporter.LoadResult result = documentImporter.load(documents);
|
||||||
|
|
||||||
|
currentStatus = new ImportStatus(ImportStatus.State.DONE, "IMPORT_DONE",
|
||||||
|
"Import abgeschlossen. " + result.processed() + " Dokumente verarbeitet.",
|
||||||
|
result.processed(), result.skippedFiles(), currentStatus.startedAt());
|
||||||
|
} catch (DomainException e) {
|
||||||
|
log.error("Canonical import failed: {}", e.getMessage());
|
||||||
|
currentStatus = new ImportStatus(ImportStatus.State.FAILED, "IMPORT_FAILED_ARTIFACT",
|
||||||
|
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Canonical import failed", e);
|
||||||
|
currentStatus = new ImportStatus(ImportStatus.State.FAILED, "IMPORT_FAILED_INTERNAL",
|
||||||
|
"Fehler: " + e.getMessage(), 0, List.of(), currentStatus.startedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private File requireArtifact(String name) {
|
||||||
|
File artifact = new File(canonicalDir, name);
|
||||||
|
if (!artifact.isFile()) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.IMPORT_ARTIFACT_INVALID,
|
||||||
|
"Missing canonical artifact: " + name);
|
||||||
|
}
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import org.apache.poi.ss.usermodel.Cell;
|
||||||
|
import org.apache.poi.ss.usermodel.DateUtil;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
|
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value-level POI helper for the canonical import artifacts. No Spring, no domain
|
||||||
|
* knowledge: it opens a workbook, maps the header row to column indices by name, and
|
||||||
|
* yields typed rows whose cells are looked up by header name — the seam that replaces
|
||||||
|
* the old positional {@code @Value app.import.col.*} indices. List columns are split on
|
||||||
|
* the pipe delimiter the normalizer emits.
|
||||||
|
*/
|
||||||
|
public final class CanonicalSheetReader {
|
||||||
|
|
||||||
|
private CanonicalSheetReader() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single data row, addressable by canonical header name (never by index). */
|
||||||
|
public static final class Row {
|
||||||
|
|
||||||
|
private final Map<String, Integer> headerIndex;
|
||||||
|
private final List<String> cells;
|
||||||
|
|
||||||
|
private Row(Map<String, Integer> headerIndex, List<String> cells) {
|
||||||
|
this.headerIndex = headerIndex;
|
||||||
|
this.cells = cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trimmed cell value for the named header, or "" when absent/blank. */
|
||||||
|
public String get(String header) {
|
||||||
|
Integer index = headerIndex.get(header);
|
||||||
|
if (index == null || index >= cells.size()) return "";
|
||||||
|
String value = cells.get(index);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads all data rows from the first sheet, validating that every required header is
|
||||||
|
* present. Throws a fail-closed {@link DomainException} on a missing header so a
|
||||||
|
* loader never silently maps the wrong column.
|
||||||
|
*/
|
||||||
|
public static List<Row> readRows(File file, List<String> requiredHeaders) {
|
||||||
|
try (FileInputStream fis = new FileInputStream(file);
|
||||||
|
Workbook workbook = WorkbookFactory.create(fis)) {
|
||||||
|
|
||||||
|
Sheet sheet = workbook.getSheetAt(0);
|
||||||
|
org.apache.poi.ss.usermodel.Row headerRow = sheet.getRow(sheet.getFirstRowNum());
|
||||||
|
Map<String, Integer> headerIndex = mapHeaders(headerRow);
|
||||||
|
requireHeaders(file, headerIndex, requiredHeaders);
|
||||||
|
|
||||||
|
List<Row> rows = new ArrayList<>();
|
||||||
|
for (int i = sheet.getFirstRowNum() + 1; i <= sheet.getLastRowNum(); i++) {
|
||||||
|
org.apache.poi.ss.usermodel.Row poiRow = sheet.getRow(i);
|
||||||
|
if (poiRow == null) continue;
|
||||||
|
rows.add(new Row(headerIndex, readCells(poiRow, headerIndex.size())));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
} catch (DomainException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.IMPORT_ARTIFACT_INVALID,
|
||||||
|
"Unreadable canonical artifact: " + file.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits a pipe-delimited list column into trimmed, non-empty segments. */
|
||||||
|
public static List<String> splitList(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) return List.of();
|
||||||
|
return Arrays.stream(raw.split("\\|"))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Integer> mapHeaders(org.apache.poi.ss.usermodel.Row headerRow) {
|
||||||
|
if (headerRow == null) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
Map<String, Integer> headerIndex = new HashMap<>();
|
||||||
|
for (int c = 0; c < headerRow.getLastCellNum(); c++) {
|
||||||
|
String name = cellToString(headerRow.getCell(c)).trim();
|
||||||
|
if (!name.isEmpty()) headerIndex.putIfAbsent(name, c);
|
||||||
|
}
|
||||||
|
return headerIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireHeaders(File file, Map<String, Integer> headerIndex, List<String> requiredHeaders) {
|
||||||
|
for (String header : requiredHeaders) {
|
||||||
|
if (!headerIndex.containsKey(header)) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.IMPORT_ARTIFACT_INVALID,
|
||||||
|
"Missing required header '" + header + "' in artifact " + file.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> readCells(org.apache.poi.ss.usermodel.Row poiRow, int columnCount) {
|
||||||
|
int width = Math.max(columnCount, poiRow.getLastCellNum());
|
||||||
|
List<String> cells = new ArrayList<>(width);
|
||||||
|
for (int c = 0; c < width; c++) {
|
||||||
|
cells.add(cellToString(poiRow.getCell(c)));
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String cellToString(Cell cell) {
|
||||||
|
if (cell == null) return "";
|
||||||
|
return switch (cell.getCellType()) {
|
||||||
|
case STRING -> cell.getStringCellValue();
|
||||||
|
case NUMERIC -> {
|
||||||
|
if (DateUtil.isCellDateFormatted(cell)) {
|
||||||
|
yield cell.getLocalDateTimeCellValue().toLocalDate().toString();
|
||||||
|
}
|
||||||
|
yield String.valueOf((long) cell.getNumericCellValue());
|
||||||
|
}
|
||||||
|
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
|
||||||
|
default -> "";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
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.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import software.amazon.awssdk.core.sync.RequestBody;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.tag.TagService;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads {@code canonical-documents.xlsx} into the document domain. Java performs no
|
||||||
|
* semantic transformation: the normalizer already resolved people to slugs and dates to
|
||||||
|
* ISO values. This loader maps columns by header name, routes each attribution
|
||||||
|
* register-first (always retaining the raw cell in {@code sender_text}/{@code receiver_text}),
|
||||||
|
* parses clean dates, and keeps the file/S3/thumbnail plumbing.
|
||||||
|
*
|
||||||
|
* <p>The {@code file} value is hostile input regardless of upstream trust (CWE-22 does not
|
||||||
|
* care that it came from our Python tool): its basename is validated with
|
||||||
|
* {@link #isValidImportFilename} and then resolved with canonical-path containment in
|
||||||
|
* {@link #findFileRecursive}.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class DocumentImporter {
|
||||||
|
|
||||||
|
static final List<String> REQUIRED_HEADERS = List.of(
|
||||||
|
"index", "file", "sender_person_id", "sender_name",
|
||||||
|
"receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision");
|
||||||
|
|
||||||
|
private final DocumentService documentService;
|
||||||
|
private final PersonService personService;
|
||||||
|
private final TagService tagService;
|
||||||
|
private final S3Client s3Client;
|
||||||
|
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||||
|
|
||||||
|
@Value("${app.s3.bucket:familienarchiv}")
|
||||||
|
private String bucketName;
|
||||||
|
|
||||||
|
@Value("${app.import.dir:/import}")
|
||||||
|
private String importDir;
|
||||||
|
|
||||||
|
/** Outcome of loading the document sheet: processed count + per-file skips. */
|
||||||
|
public record LoadResult(int processed, List<ImportStatus.SkippedFile> skippedFiles) {}
|
||||||
|
|
||||||
|
// One transaction for the whole sheet keeps the Hibernate session open so an existing
|
||||||
|
// document's lazy receivers collection initialises during an idempotent re-import.
|
||||||
|
// Invoked cross-bean from the orchestrator, so the @Transactional proxy applies.
|
||||||
|
@Transactional
|
||||||
|
public LoadResult load(File artifact) {
|
||||||
|
List<CanonicalSheetReader.Row> rows = CanonicalSheetReader.readRows(artifact, REQUIRED_HEADERS);
|
||||||
|
int processed = 0;
|
||||||
|
List<ImportStatus.SkippedFile> skipped = new ArrayList<>();
|
||||||
|
for (CanonicalSheetReader.Row row : rows) {
|
||||||
|
String index = row.get("index");
|
||||||
|
if (index.isBlank()) continue;
|
||||||
|
Optional<ImportStatus.SkipReason> skipReason = importRow(row, index, skipped);
|
||||||
|
if (skipReason.isPresent()) {
|
||||||
|
skipped.add(new ImportStatus.SkippedFile(displayName(row, index), skipReason.get()));
|
||||||
|
} else {
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("Imported {} documents from {} ({} skipped)", processed, artifact.getName(), skipped.size());
|
||||||
|
return new LoadResult(processed, skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<ImportStatus.SkipReason> importRow(CanonicalSheetReader.Row row, String index,
|
||||||
|
List<ImportStatus.SkippedFile> skipped) {
|
||||||
|
Optional<File> resolved;
|
||||||
|
try {
|
||||||
|
resolved = resolveFile(row.get("file"));
|
||||||
|
} catch (InvalidImportFilenameException e) {
|
||||||
|
log.warn("Skipping import row {}: filename rejected", index);
|
||||||
|
return Optional.of(ImportStatus.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
|
||||||
|
}
|
||||||
|
if (resolved.isPresent()) {
|
||||||
|
try {
|
||||||
|
if (!isPdfMagicBytes(resolved.get())) {
|
||||||
|
return Optional.of(ImportStatus.SkipReason.INVALID_PDF_SIGNATURE);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Magic-byte check failed for row {}", index, e);
|
||||||
|
return Optional.of(ImportStatus.SkipReason.FILE_READ_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return persist(row, index, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<ImportStatus.SkipReason> persist(CanonicalSheetReader.Row row, String index, Optional<File> file) {
|
||||||
|
Document existing = documentService.findByOriginalFilename(index).orElse(null);
|
||||||
|
if (existing != null && existing.getStatus() != DocumentStatus.PLACEHOLDER) {
|
||||||
|
return Optional.of(ImportStatus.SkipReason.ALREADY_EXISTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
String s3Key = null;
|
||||||
|
String contentType = null;
|
||||||
|
DocumentStatus status = DocumentStatus.PLACEHOLDER;
|
||||||
|
if (file.isPresent()) {
|
||||||
|
contentType = probeContentType(file.get());
|
||||||
|
s3Key = "documents/" + UUID.randomUUID() + "_" + file.get().getName();
|
||||||
|
try {
|
||||||
|
uploadToS3(file.get(), s3Key, contentType);
|
||||||
|
status = DocumentStatus.UPLOADED;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("S3 upload failed for {}", file.get().getName(), e);
|
||||||
|
return Optional.of(ImportStatus.SkipReason.S3_UPLOAD_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Document doc = buildDocument(row, index, existing, s3Key, contentType, status);
|
||||||
|
Document saved = documentService.save(doc);
|
||||||
|
if (file.isPresent()) {
|
||||||
|
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document buildDocument(CanonicalSheetReader.Row row, String index, Document existing,
|
||||||
|
String s3Key, String contentType, DocumentStatus status) {
|
||||||
|
Document doc = existing != null ? existing
|
||||||
|
: Document.builder().originalFilename(index).build();
|
||||||
|
|
||||||
|
String senderName = row.get("sender_name");
|
||||||
|
String receiverNames = row.get("receiver_names");
|
||||||
|
Person sender = resolveSender(row.get("sender_person_id"), senderName);
|
||||||
|
Set<Person> receivers = resolveReceivers(row.get("receiver_person_ids"));
|
||||||
|
|
||||||
|
LocalDate date = parseIsoDate(row.get("date_iso"));
|
||||||
|
DatePrecision precision = parsePrecision(row.get("date_precision"));
|
||||||
|
LocalDate dateEnd = parseIsoDate(row.get("date_end"));
|
||||||
|
String dateRaw = blankToNull(row.get("date_raw"));
|
||||||
|
String location = blankToNull(row.get("location"));
|
||||||
|
|
||||||
|
doc.setTitle(buildTitle(index, date, precision, dateEnd, dateRaw, location));
|
||||||
|
doc.setStatus(status);
|
||||||
|
doc.setFilePath(s3Key);
|
||||||
|
doc.setContentType(contentType);
|
||||||
|
doc.setSender(sender);
|
||||||
|
doc.setSenderText(blankToNull(senderName));
|
||||||
|
// The canonical row is authoritative for receivers/tags (ADR-025): clear then
|
||||||
|
// re-populate so a shrunk set on re-import prunes stale links rather than
|
||||||
|
// accumulating them. The raw sender_text/receiver_text retention is separate.
|
||||||
|
doc.getReceivers().clear();
|
||||||
|
doc.getReceivers().addAll(receivers);
|
||||||
|
doc.setReceiverText(blankToNull(receiverNames));
|
||||||
|
doc.setDocumentDate(date);
|
||||||
|
doc.setMetaDatePrecision(precision);
|
||||||
|
doc.setMetaDateEnd(dateEnd);
|
||||||
|
doc.setMetaDateRaw(dateRaw);
|
||||||
|
doc.setLocation(location);
|
||||||
|
doc.setSummary(blankToNull(row.get("summary")));
|
||||||
|
attachTag(doc, row.get("tags"));
|
||||||
|
doc.setMetadataComplete(doc.getDocumentDate() != null || sender != null || !receivers.isEmpty());
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (slug.isBlank()) return null;
|
||||||
|
return resolvePerson(slug, rawName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<Person> resolveReceivers(String slugs) {
|
||||||
|
Set<Person> receivers = new LinkedHashSet<>();
|
||||||
|
for (String slug : CanonicalSheetReader.splitList(slugs)) {
|
||||||
|
receivers.add(resolvePerson(slug, slug));
|
||||||
|
}
|
||||||
|
return receivers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Person resolvePerson(String slug, String rawName) {
|
||||||
|
return personService.findBySourceRef(slug)
|
||||||
|
.orElseGet(() -> personService.upsertBySourceRef(PersonUpsertCommand.builder()
|
||||||
|
.sourceRef(slug)
|
||||||
|
.lastName(blankToNull(rawName) == null ? slug : rawName)
|
||||||
|
.personType(PersonType.PERSON)
|
||||||
|
.provisional(true)
|
||||||
|
.build()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authoritative: the canonical row defines the document's tags exactly. Clearing first
|
||||||
|
// means a tag removed from the row is pruned on re-import (ADR-025).
|
||||||
|
private void attachTag(Document doc, String tagPath) {
|
||||||
|
doc.getTags().clear();
|
||||||
|
if (tagPath.isBlank()) return;
|
||||||
|
tagService.findBySourceRef(tagPath).ifPresent(tag -> doc.getTags().add(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── clean-value parsing (no semantic logic) ─────────────────────────────────────
|
||||||
|
|
||||||
|
private static LocalDate parseIsoDate(String value) {
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(value.trim());
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DatePrecision parsePrecision(String value) {
|
||||||
|
if (value == null || value.isBlank()) return DatePrecision.UNKNOWN;
|
||||||
|
try {
|
||||||
|
return DatePrecision.valueOf(value.trim());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return DatePrecision.UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── file handling + S3 (small ≤20-line methods) ─────────────────────────────────
|
||||||
|
|
||||||
|
private Optional<File> resolveFile(String fileColumn) {
|
||||||
|
if (fileColumn == null || fileColumn.isBlank()) return Optional.empty();
|
||||||
|
String basename = basenameOf(fileColumn);
|
||||||
|
if (!isValidImportFilename(basename)) {
|
||||||
|
throw new InvalidImportFilenameException();
|
||||||
|
}
|
||||||
|
return findFileRecursive(basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String basenameOf(String fileColumn) {
|
||||||
|
String normalized = fileColumn.replace('\\', '/');
|
||||||
|
int lastSlash = normalized.lastIndexOf('/');
|
||||||
|
return lastSlash < 0 ? normalized.trim() : normalized.substring(lastSlash + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String probeContentType(File file) {
|
||||||
|
try {
|
||||||
|
String probed = Files.probeContentType(file.toPath());
|
||||||
|
return probed != null ? probed : "application/octet-stream";
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadToS3(File file, String s3Key, String contentType) {
|
||||||
|
s3Client.putObject(PutObjectRequest.builder()
|
||||||
|
.bucket(bucketName)
|
||||||
|
.key(s3Key)
|
||||||
|
.contentType(contentType)
|
||||||
|
.build(),
|
||||||
|
RequestBody.fromFile(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── security guards — ported verbatim from MassImportService — do not weaken ────
|
||||||
|
|
||||||
|
private boolean isValidImportFilename(String filename) {
|
||||||
|
if (filename == null || filename.isBlank()) return false;
|
||||||
|
if (filename.contains("/")) return false;
|
||||||
|
if (filename.contains("\\")) return false;
|
||||||
|
if (filename.contains("∕")) return false; // U+2215 DIVISION SLASH
|
||||||
|
if (filename.contains("/")) return false; // U+FF0F FULLWIDTH SOLIDUS
|
||||||
|
if (filename.contains("⧵")) return false; // U+29F5 REVERSE SOLIDUS OPERATOR
|
||||||
|
if (filename.contains("..")) return false;
|
||||||
|
if (filename.equals(".")) return false;
|
||||||
|
if (filename.contains("\0")) return false;
|
||||||
|
if (Paths.get(filename).isAbsolute()) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// package-private: a Mockito spy in tests can override to inject IOException
|
||||||
|
InputStream openFileStream(File file) throws IOException {
|
||||||
|
return new FileInputStream(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isPdfMagicBytes(File file) throws IOException {
|
||||||
|
try (InputStream is = openFileStream(file)) {
|
||||||
|
byte[] header = is.readNBytes(4);
|
||||||
|
return header.length == 4
|
||||||
|
&& header[0] == 0x25 // %
|
||||||
|
&& header[1] == 0x50 // P
|
||||||
|
&& header[2] == 0x44 // D
|
||||||
|
&& header[3] == 0x46; // F
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<File> findFileRecursive(String filename) {
|
||||||
|
File baseDir = new File(importDir);
|
||||||
|
try (Stream<Path> walk = Files.walk(baseDir.toPath())) {
|
||||||
|
Optional<Path> match = walk.filter(p -> !Files.isDirectory(p))
|
||||||
|
.filter(p -> p.getFileName().toString().equals(filename))
|
||||||
|
.findFirst();
|
||||||
|
if (match.isEmpty()) return Optional.empty();
|
||||||
|
File candidate = match.get().toFile();
|
||||||
|
String baseDirCanonical = baseDir.getCanonicalPath();
|
||||||
|
if (!candidate.getCanonicalPath().startsWith(baseDirCanonical + File.separator)) {
|
||||||
|
throw DomainException.internal(ErrorCode.INTERNAL_ERROR, "Path escape detected: " + candidate);
|
||||||
|
}
|
||||||
|
return Optional.of(candidate);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String displayName(CanonicalSheetReader.Row row, String index) {
|
||||||
|
String file = row.get("file");
|
||||||
|
return file.isBlank() ? index : basenameOf(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class InvalidImportFilenameException extends RuntimeException {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces the honest German date label baked into an import title — at exactly
|
||||||
|
* the precision the data claims, never finer. This is the Java half of the
|
||||||
|
* single source of truth shared with the frontend {@code formatDocumentDate}
|
||||||
|
* (TypeScript): both are asserted against {@code docs/date-label-fixtures.json}
|
||||||
|
* so the two implementations cannot drift (see #666).
|
||||||
|
*
|
||||||
|
* <p>Import titles are always German, so the labels here are the German
|
||||||
|
* canonical form (mirroring the {@code de} Paraglide messages used by the UI).
|
||||||
|
*/
|
||||||
|
final class DocumentTitleFormatter {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter LONG = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN);
|
||||||
|
private static final DateTimeFormatter MONTH_YEAR = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.GERMAN);
|
||||||
|
private static final DateTimeFormatter MEDIUM = DateTimeFormatter.ofPattern("d. MMM yyyy", Locale.GERMAN);
|
||||||
|
private static final DateTimeFormatter DAY_MONTH = DateTimeFormatter.ofPattern("d. MMM", Locale.GERMAN);
|
||||||
|
|
||||||
|
private static final String UNKNOWN = "Datum unbekannt";
|
||||||
|
private static final String APPROX_PREFIX = "ca.";
|
||||||
|
private static final String OPEN_RANGE_PREFIX = "ab";
|
||||||
|
|
||||||
|
private DocumentTitleFormatter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param date the sort/filter anchor day; null for UNKNOWN rows
|
||||||
|
* @param precision descriptive precision metadata
|
||||||
|
* @param end the RANGE end day; null means an open-ended range
|
||||||
|
* @param raw the verbatim spreadsheet cell, used only to pick a season word
|
||||||
|
* @return the honest German label
|
||||||
|
*/
|
||||||
|
static String formatTitleDate(LocalDate date, DatePrecision precision, LocalDate end, String raw) {
|
||||||
|
if (precision == DatePrecision.UNKNOWN || date == null) {
|
||||||
|
return UNKNOWN;
|
||||||
|
}
|
||||||
|
return switch (precision) {
|
||||||
|
case DAY -> LONG.format(date);
|
||||||
|
case MONTH -> MONTH_YEAR.format(date);
|
||||||
|
case SEASON -> seasonLabel(date, raw);
|
||||||
|
case YEAR -> String.valueOf(date.getYear());
|
||||||
|
case APPROX -> APPROX_PREFIX + " " + date.getYear();
|
||||||
|
case RANGE -> rangeLabel(date, end);
|
||||||
|
case UNKNOWN -> UNKNOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String seasonLabel(LocalDate date, String raw) {
|
||||||
|
Season season = seasonFromRaw(raw);
|
||||||
|
if (season == null) {
|
||||||
|
season = seasonOfMonth(date.getMonthValue());
|
||||||
|
}
|
||||||
|
return season.german + " " + date.getYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String rangeLabel(LocalDate start, LocalDate end) {
|
||||||
|
if (end == null) {
|
||||||
|
return OPEN_RANGE_PREFIX + " " + MEDIUM.format(start);
|
||||||
|
}
|
||||||
|
if (end.equals(start)) {
|
||||||
|
return MEDIUM.format(start);
|
||||||
|
}
|
||||||
|
if (start.getYear() != end.getYear()) {
|
||||||
|
return MEDIUM.format(start) + " – " + MEDIUM.format(end);
|
||||||
|
}
|
||||||
|
if (start.getMonthValue() == end.getMonthValue()) {
|
||||||
|
return start.getDayOfMonth() + ".–" + MEDIUM.format(end);
|
||||||
|
}
|
||||||
|
return DAY_MONTH.format(start) + " – " + MEDIUM.format(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── season mapping — mirrors the normalizer's representative months ─────────────
|
||||||
|
|
||||||
|
private enum Season {
|
||||||
|
SPRING("Frühling"),
|
||||||
|
SUMMER("Sommer"),
|
||||||
|
AUTUMN("Herbst"),
|
||||||
|
WINTER("Winter");
|
||||||
|
|
||||||
|
private final String german;
|
||||||
|
|
||||||
|
Season(String german) {
|
||||||
|
this.german = german;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Season seasonOfMonth(int month) {
|
||||||
|
if (month >= 3 && month <= 5) return Season.SPRING;
|
||||||
|
if (month >= 6 && month <= 8) return Season.SUMMER;
|
||||||
|
if (month >= 9 && month <= 11) return Season.AUTUMN;
|
||||||
|
return Season.WINTER;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Season seasonFromRaw(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
String token = raw.trim().split("\\s+")[0].toLowerCase(Locale.GERMAN);
|
||||||
|
return switch (token) {
|
||||||
|
case "frühling", "frühjahr" -> Season.SPRING;
|
||||||
|
case "sommer" -> Season.SUMMER;
|
||||||
|
case "herbst" -> Season.AUTUMN;
|
||||||
|
case "winter" -> Season.WINTER;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async import state surfaced to {@code admin/system/ImportStatusCard.svelte} via the
|
||||||
|
* generated types. The shape ({@code state, statusCode, processed, skippedFiles, skipped})
|
||||||
|
* is kept verbatim from the retired MassImportService so the admin UI keeps working.
|
||||||
|
*/
|
||||||
|
public record ImportStatus(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) State state,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String statusCode,
|
||||||
|
@JsonIgnore String message,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int processed,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<SkippedFile> skippedFiles,
|
||||||
|
LocalDateTime startedAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public enum State { IDLE, RUNNING, DONE, FAILED }
|
||||||
|
|
||||||
|
public enum SkipReason {
|
||||||
|
INVALID_FILENAME_PATH_TRAVERSAL,
|
||||||
|
INVALID_PDF_SIGNATURE,
|
||||||
|
FILE_READ_ERROR,
|
||||||
|
ALREADY_EXISTS,
|
||||||
|
S3_UPLOAD_FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SkippedFile(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String filename,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) SkipReason reason
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Note: @Schema on a record accessor method is not picked up by SpringDoc; the
|
||||||
|
// "skipped" count is a computed convenience field derived from skippedFiles.size().
|
||||||
|
@JsonProperty("skipped")
|
||||||
|
public int skipped() {
|
||||||
|
return skippedFiles.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Defensive-copy constructor — callers cannot mutate the stored list after construction. */
|
||||||
|
public ImportStatus {
|
||||||
|
skippedFiles = List.copyOf(skippedFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.poi.ss.usermodel.*;
|
|
||||||
import java.util.Objects;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
|
||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
|
||||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.tag.Tag;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.person.PersonNameParser;
|
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
|
||||||
import org.raddatz.familienarchiv.tag.TagService;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.w3c.dom.Element;
|
|
||||||
import org.w3c.dom.NodeList;
|
|
||||||
import software.amazon.awssdk.core.sync.RequestBody;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
|
||||||
|
|
||||||
import javax.xml.parsers.DocumentBuilderFactory;
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.time.format.DateTimeParseException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
import java.util.zip.ZipFile;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class MassImportService {
|
|
||||||
|
|
||||||
public enum State { IDLE, RUNNING, DONE, FAILED }
|
|
||||||
|
|
||||||
public record ImportStatus(State state, String statusCode, @JsonIgnore String message, int processed, LocalDateTime startedAt) {}
|
|
||||||
|
|
||||||
private volatile ImportStatus currentStatus = new ImportStatus(State.IDLE, "IMPORT_IDLE", "Kein Import gestartet.", 0, null);
|
|
||||||
|
|
||||||
public ImportStatus getStatus() {
|
|
||||||
return currentStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
private final DocumentService documentService;
|
|
||||||
private final PersonService personService;
|
|
||||||
private final TagService tagService;
|
|
||||||
private final S3Client s3Client;
|
|
||||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
|
||||||
|
|
||||||
@Value("${app.s3.bucket}")
|
|
||||||
private String bucketName;
|
|
||||||
|
|
||||||
@Value("${app.import.col.index:0}")
|
|
||||||
private int colIndex;
|
|
||||||
|
|
||||||
@Value("${app.import.col.box:1}")
|
|
||||||
private int colBox;
|
|
||||||
|
|
||||||
@Value("${app.import.col.folder:2}")
|
|
||||||
private int colFolder;
|
|
||||||
|
|
||||||
@Value("${app.import.col.sender:3}")
|
|
||||||
private int colSender;
|
|
||||||
|
|
||||||
@Value("${app.import.col.receivers:5}")
|
|
||||||
private int colReceivers;
|
|
||||||
|
|
||||||
@Value("${app.import.col.date:7}")
|
|
||||||
private int colDate;
|
|
||||||
|
|
||||||
@Value("${app.import.col.location:9}")
|
|
||||||
private int colLocation;
|
|
||||||
|
|
||||||
@Value("${app.import.col.tags:10}")
|
|
||||||
private int colTags;
|
|
||||||
|
|
||||||
@Value("${app.import.col.summary:11}")
|
|
||||||
private int colSummary;
|
|
||||||
|
|
||||||
@Value("${app.import.col.transcription:13}")
|
|
||||||
private int colTranscription;
|
|
||||||
|
|
||||||
@Value("${app.import.dir:/import}")
|
|
||||||
private String importDir;
|
|
||||||
|
|
||||||
private static final DateTimeFormatter GERMAN_DATE = DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN);
|
|
||||||
|
|
||||||
// ODS XML namespaces
|
|
||||||
private static final String NS_TABLE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0";
|
|
||||||
private static final String NS_TEXT = "urn:oasis:names:tc:opendocument:xmlns:text:1.0";
|
|
||||||
|
|
||||||
// We only need up to this many columns; caps repeated-empty-cell expansion
|
|
||||||
private static final int MAX_COLS = 20;
|
|
||||||
|
|
||||||
@Async
|
|
||||||
public void runImportAsync() {
|
|
||||||
if (currentStatus.state() == State.RUNNING) {
|
|
||||||
throw DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "A mass import is already in progress");
|
|
||||||
}
|
|
||||||
currentStatus = new ImportStatus(State.RUNNING, "IMPORT_RUNNING", "Import läuft...", 0, LocalDateTime.now());
|
|
||||||
try {
|
|
||||||
File spreadsheet = findSpreadsheetFile();
|
|
||||||
log.info("Starte Massenimport aus: {}", spreadsheet.getAbsolutePath());
|
|
||||||
int processed = processRows(readSpreadsheet(spreadsheet));
|
|
||||||
currentStatus = new ImportStatus(State.DONE, "IMPORT_DONE",
|
|
||||||
"Import abgeschlossen. " + processed + " Dokumente verarbeitet.",
|
|
||||||
processed, currentStatus.startedAt());
|
|
||||||
} catch (NoSpreadsheetException e) {
|
|
||||||
log.error("Massenimport fehlgeschlagen: keine Tabellendatei", e);
|
|
||||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_NO_SPREADSHEET",
|
|
||||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Massenimport fehlgeschlagen", e);
|
|
||||||
currentStatus = new ImportStatus(State.FAILED, "IMPORT_FAILED_INTERNAL",
|
|
||||||
"Fehler: " + e.getMessage(), 0, currentStatus.startedAt());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class NoSpreadsheetException extends RuntimeException {
|
|
||||||
NoSpreadsheetException(String message) { super(message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private File findSpreadsheetFile() throws IOException {
|
|
||||||
try (Stream<Path> files = Files.list(Paths.get(importDir))) {
|
|
||||||
return files
|
|
||||||
.filter(p -> {
|
|
||||||
String name = p.toString().toLowerCase();
|
|
||||||
return name.endsWith(".ods") || name.endsWith(".xlsx") || name.endsWith(".xls");
|
|
||||||
})
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow(() -> new NoSpreadsheetException(
|
|
||||||
"Keine Tabellendatei (.ods/.xlsx/.xls) in " + importDir + " gefunden!"))
|
|
||||||
.toFile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Spreadsheet reading (format-specific, produces neutral List<List<String>>) ---
|
|
||||||
|
|
||||||
private List<List<String>> readSpreadsheet(File file) throws Exception {
|
|
||||||
String name = file.getName().toLowerCase();
|
|
||||||
if (name.endsWith(".ods")) {
|
|
||||||
return readOds(file);
|
|
||||||
}
|
|
||||||
return readXlsx(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads an ODS file by parsing its content.xml directly (no extra library needed).
|
|
||||||
* ODS is a ZIP archive; content.xml holds the spreadsheet data as XML.
|
|
||||||
*/
|
|
||||||
List<List<String>> readOds(File file) throws Exception {
|
|
||||||
List<List<String>> result = new ArrayList<>();
|
|
||||||
|
|
||||||
try (ZipFile zip = new ZipFile(file)) {
|
|
||||||
var entry = zip.getEntry("content.xml");
|
|
||||||
if (entry == null) throw new RuntimeException("Ungültige ODS-Datei: content.xml fehlt");
|
|
||||||
|
|
||||||
var factory = XxeSafeXmlParser.hardenedFactory();
|
|
||||||
factory.setNamespaceAware(true);
|
|
||||||
var builder = factory.newDocumentBuilder();
|
|
||||||
var doc = builder.parse(zip.getInputStream(entry));
|
|
||||||
|
|
||||||
NodeList tables = doc.getElementsByTagNameNS(NS_TABLE, "table");
|
|
||||||
if (tables.getLength() == 0) return result;
|
|
||||||
|
|
||||||
var table = (Element) tables.item(0);
|
|
||||||
NodeList rows = table.getElementsByTagNameNS(NS_TABLE, "table-row");
|
|
||||||
|
|
||||||
for (int i = 0; i < rows.getLength(); i++) {
|
|
||||||
var row = (Element) rows.item(i);
|
|
||||||
List<String> rowData = new ArrayList<>();
|
|
||||||
NodeList cells = row.getElementsByTagNameNS(NS_TABLE, "table-cell");
|
|
||||||
|
|
||||||
for (int j = 0; j < cells.getLength() && rowData.size() < MAX_COLS; j++) {
|
|
||||||
var cell = (Element) cells.item(j);
|
|
||||||
|
|
||||||
// Read the display text (first <text:p>)
|
|
||||||
String value = "";
|
|
||||||
NodeList textNodes = cell.getElementsByTagNameNS(NS_TEXT, "p");
|
|
||||||
if (textNodes.getLength() > 0) {
|
|
||||||
value = textNodes.item(0).getTextContent().trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand number-columns-repeated (capped at MAX_COLS)
|
|
||||||
String repeatAttr = cell.getAttributeNS(NS_TABLE, "number-columns-repeated");
|
|
||||||
int repeat = repeatAttr.isEmpty() ? 1 : Integer.parseInt(repeatAttr);
|
|
||||||
repeat = Math.min(repeat, MAX_COLS - rowData.size());
|
|
||||||
|
|
||||||
for (int r = 0; r < repeat; r++) {
|
|
||||||
rowData.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.add(rowData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads an XLSX/XLS file using Apache POI. Converts all cells to strings. */
|
|
||||||
private List<List<String>> readXlsx(File file) throws Exception {
|
|
||||||
List<List<String>> result = new ArrayList<>();
|
|
||||||
try (FileInputStream fis = new FileInputStream(file);
|
|
||||||
Workbook workbook = WorkbookFactory.create(fis)) {
|
|
||||||
|
|
||||||
Sheet sheet = workbook.getSheetAt(0);
|
|
||||||
for (int i = 0; i <= sheet.getLastRowNum(); i++) {
|
|
||||||
Row row = sheet.getRow(i);
|
|
||||||
List<String> rowData = new ArrayList<>();
|
|
||||||
if (row != null) {
|
|
||||||
for (int j = 0; j < MAX_COLS; j++) {
|
|
||||||
rowData.add(xlsxCellToString(row.getCell(j)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.add(rowData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String xlsxCellToString(Cell cell) {
|
|
||||||
if (cell == null) return "";
|
|
||||||
return switch (cell.getCellType()) {
|
|
||||||
case STRING -> cell.getStringCellValue();
|
|
||||||
case NUMERIC -> {
|
|
||||||
if (DateUtil.isCellDateFormatted(cell)) {
|
|
||||||
yield cell.getLocalDateTimeCellValue().toLocalDate().toString(); // ISO
|
|
||||||
}
|
|
||||||
yield String.valueOf((int) cell.getNumericCellValue());
|
|
||||||
}
|
|
||||||
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
|
|
||||||
default -> "";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Import logic (works on neutral List<String> rows) ---
|
|
||||||
|
|
||||||
private int processRows(List<List<String>> rows) {
|
|
||||||
int count = 0;
|
|
||||||
for (int i = 1; i < rows.size(); i++) { // skip header row
|
|
||||||
List<String> cells = rows.get(i);
|
|
||||||
String index = getCell(cells, colIndex);
|
|
||||||
if (index.isBlank()) continue;
|
|
||||||
|
|
||||||
String filename = index.contains(".") ? index : index + ".pdf";
|
|
||||||
Optional<File> fileOnDisk = findFileRecursive(filename);
|
|
||||||
if (fileOnDisk.isEmpty()) {
|
|
||||||
log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename);
|
|
||||||
}
|
|
||||||
importSingleDocument(cells, fileOnDisk, filename, index);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
|
|
||||||
Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
|
|
||||||
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
|
|
||||||
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String archiveBox = getCell(cells, colBox);
|
|
||||||
String archiveFolder = getCell(cells, colFolder);
|
|
||||||
String senderRaw = getCell(cells, colSender);
|
|
||||||
String receiversRaw = getCell(cells, colReceivers);
|
|
||||||
LocalDate date = parseDate(getCell(cells, colDate));
|
|
||||||
String location = getCell(cells, colLocation);
|
|
||||||
String tagRaw = getCell(cells, colTags);
|
|
||||||
String summary = getCell(cells, colSummary);
|
|
||||||
String transcription = getCell(cells, colTranscription);
|
|
||||||
|
|
||||||
String s3Key = null;
|
|
||||||
String contentType = null;
|
|
||||||
DocumentStatus status = DocumentStatus.PLACEHOLDER;
|
|
||||||
|
|
||||||
if (file.isPresent()) {
|
|
||||||
try {
|
|
||||||
contentType = Files.probeContentType(file.get().toPath());
|
|
||||||
} catch (IOException e) {
|
|
||||||
contentType = null;
|
|
||||||
}
|
|
||||||
if (contentType == null) contentType = "application/octet-stream";
|
|
||||||
|
|
||||||
s3Key = "documents/" + UUID.randomUUID() + "_" + file.get().getName();
|
|
||||||
try {
|
|
||||||
s3Client.putObject(PutObjectRequest.builder()
|
|
||||||
.bucket(bucketName)
|
|
||||||
.key(s3Key)
|
|
||||||
.contentType(contentType)
|
|
||||||
.build(),
|
|
||||||
RequestBody.fromFile(file.get()));
|
|
||||||
status = DocumentStatus.UPLOADED;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("S3 Upload Fehler für {}", file.get().getName(), e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Person sender = senderRaw.isBlank() ? null : findOrCreatePerson(senderRaw);
|
|
||||||
List<Person> receivers = PersonNameParser.parseReceivers(receiversRaw).stream()
|
|
||||||
.map(this::findOrCreatePerson)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
Tag tag = null;
|
|
||||||
if (!tagRaw.isBlank()) {
|
|
||||||
tag = tagService.findOrCreate(tagRaw);
|
|
||||||
}
|
|
||||||
|
|
||||||
Document doc = existing.orElse(Document.builder()
|
|
||||||
.originalFilename(originalFilename)
|
|
||||||
.build());
|
|
||||||
|
|
||||||
// Heuristic: mark as complete if at least one key field is present in the spreadsheet row
|
|
||||||
boolean metadataComplete = date != null || !senderRaw.isBlank() || !receiversRaw.isBlank();
|
|
||||||
|
|
||||||
doc.setTitle(buildTitle(index, date, location));
|
|
||||||
doc.setFilePath(s3Key);
|
|
||||||
doc.setContentType(contentType);
|
|
||||||
doc.setStatus(status);
|
|
||||||
doc.setArchiveBox(archiveBox.isBlank() ? null : archiveBox);
|
|
||||||
doc.setArchiveFolder(archiveFolder.isBlank() ? null : archiveFolder);
|
|
||||||
doc.setDocumentDate(date);
|
|
||||||
doc.setLocation(location.isBlank() ? null : location);
|
|
||||||
doc.setSummary(summary.isBlank() ? null : summary);
|
|
||||||
doc.setTranscription(transcription.isBlank() ? null : transcription);
|
|
||||||
doc.setSender(sender);
|
|
||||||
doc.getReceivers().addAll(receivers);
|
|
||||||
if (tag != null) doc.getTags().add(tag);
|
|
||||||
doc.setMetadataComplete(metadataComplete);
|
|
||||||
|
|
||||||
Document saved = documentService.save(doc);
|
|
||||||
if (file.isPresent()) {
|
|
||||||
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
|
|
||||||
}
|
|
||||||
log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
private String getCell(List<String> cells, int col) {
|
|
||||||
if (col >= cells.size()) return "";
|
|
||||||
String val = cells.get(col);
|
|
||||||
return val == null ? "" : val.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private LocalDate parseDate(String value) {
|
|
||||||
if (value == null || value.isBlank()) return null;
|
|
||||||
try {
|
|
||||||
return LocalDate.parse(value.trim());
|
|
||||||
} catch (DateTimeParseException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildTitle(String index, LocalDate date, String location) {
|
|
||||||
StringBuilder sb = new StringBuilder(index);
|
|
||||||
if (date != null) {
|
|
||||||
sb.append(" \u2013 ").append(date.format(GERMAN_DATE));
|
|
||||||
}
|
|
||||||
if (location != null && !location.isBlank()) {
|
|
||||||
sb.append(" \u2013 ").append(location);
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Person findOrCreatePerson(String rawName) {
|
|
||||||
return personService.findOrCreateByAlias(rawName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Optional<File> findFileRecursive(String filename) {
|
|
||||||
try (Stream<Path> walk = Files.walk(Paths.get(importDir))) {
|
|
||||||
return walk.filter(p -> !Files.isDirectory(p))
|
|
||||||
.filter(p -> p.getFileName().toString().equals(filename))
|
|
||||||
.map(Path::toFile)
|
|
||||||
.findFirst();
|
|
||||||
} catch (IOException e) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads {@code canonical-persons.xlsx} (the register) into the person domain via
|
||||||
|
* {@link PersonService}, upserting each person by the normalizer {@code person_id}
|
||||||
|
* (source_ref). Register persons are confident identities, so {@code provisional} is
|
||||||
|
* driven by the sheet's already-clean value (normally {@code False}).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class PersonRegisterImporter {
|
||||||
|
|
||||||
|
static final List<String> REQUIRED_HEADERS = List.of("person_id", "last_name", "first_name", "provisional");
|
||||||
|
|
||||||
|
private final PersonService personService;
|
||||||
|
|
||||||
|
public int load(File artifact) {
|
||||||
|
List<CanonicalSheetReader.Row> rows = CanonicalSheetReader.readRows(artifact, REQUIRED_HEADERS);
|
||||||
|
int processed = 0;
|
||||||
|
for (CanonicalSheetReader.Row row : rows) {
|
||||||
|
String personId = row.get("person_id");
|
||||||
|
if (personId.isBlank()) continue;
|
||||||
|
personService.upsertBySourceRef(toCommand(row, personId));
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
log.info("Imported {} register persons from {}", processed, artifact.getName());
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PersonUpsertCommand toCommand(CanonicalSheetReader.Row row, String personId) {
|
||||||
|
return PersonUpsertCommand.builder()
|
||||||
|
.sourceRef(personId)
|
||||||
|
.lastName(blankToNull(row.get("last_name")))
|
||||||
|
.firstName(blankToNull(row.get("first_name")))
|
||||||
|
.maidenName(blankToNull(row.get("maiden_name")))
|
||||||
|
.notes(blankToNull(row.get("notes")))
|
||||||
|
.birthYear(yearOf(row.get("birth_date")))
|
||||||
|
.deathYear(yearOf(row.get("death_date")))
|
||||||
|
.personType(PersonType.PERSON)
|
||||||
|
.provisional(Boolean.parseBoolean(row.get("provisional")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer yearOf(String isoDate) {
|
||||||
|
if (isoDate == null || isoDate.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(isoDate.trim()).getYear();
|
||||||
|
} catch (DateTimeParseException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads {@code canonical-persons-tree.json} into the person + relationship domains.
|
||||||
|
* Tree persons are upserted via {@link PersonService} keyed on the shared
|
||||||
|
* {@code personId} slug (which Phase 1 #670 now emits into the tree), so they reconcile
|
||||||
|
* with the register rather than duplicating it. Relationships reference persons by the
|
||||||
|
* tree's local {@code rowId}; each side is mapped to the upserted person's UUID and
|
||||||
|
* created through {@link RelationshipService} (never the relationship repository —
|
||||||
|
* layering rule). A duplicate relationship on re-import is swallowed for idempotency.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class PersonTreeImporter {
|
||||||
|
|
||||||
|
// The tree JSON is a local implementation detail, not a shared API payload, so the
|
||||||
|
// importer owns its own mapper rather than depending on the web ObjectMapper bean.
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private final PersonService personService;
|
||||||
|
private final RelationshipService relationshipService;
|
||||||
|
|
||||||
|
public int load(File artifact) {
|
||||||
|
JsonNode root = readTree(artifact);
|
||||||
|
Map<String, UUID> idByRowId = upsertPersons(root.path("persons"));
|
||||||
|
int relationships = createRelationships(root.path("relationships"), idByRowId);
|
||||||
|
log.info("Imported {} tree persons and {} relationships from {}",
|
||||||
|
idByRowId.size(), relationships, artifact.getName());
|
||||||
|
return idByRowId.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode readTree(File artifact) {
|
||||||
|
try {
|
||||||
|
return OBJECT_MAPPER.readTree(artifact);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.IMPORT_ARTIFACT_INVALID,
|
||||||
|
"Unreadable canonical artifact: " + artifact.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, UUID> upsertPersons(JsonNode persons) {
|
||||||
|
Map<String, UUID> idByRowId = new HashMap<>();
|
||||||
|
for (JsonNode node : persons) {
|
||||||
|
String personId = text(node, "personId");
|
||||||
|
if (personId.isBlank()) continue;
|
||||||
|
Person person = personService.upsertBySourceRef(toCommand(node, personId));
|
||||||
|
idByRowId.put(text(node, "rowId"), person.getId());
|
||||||
|
}
|
||||||
|
return idByRowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PersonUpsertCommand toCommand(JsonNode node, String personId) {
|
||||||
|
return PersonUpsertCommand.builder()
|
||||||
|
.sourceRef(personId)
|
||||||
|
.lastName(blankToNull(text(node, "lastName")))
|
||||||
|
.firstName(blankToNull(text(node, "firstName")))
|
||||||
|
.maidenName(blankToNull(text(node, "maidenName")))
|
||||||
|
.notes(blankToNull(text(node, "notes")))
|
||||||
|
.birthYear(intOrNull(node, "birthYear"))
|
||||||
|
.deathYear(intOrNull(node, "deathYear"))
|
||||||
|
.familyMember(node.path("familyMember").asBoolean(false))
|
||||||
|
.personType(PersonType.PERSON)
|
||||||
|
.provisional(false)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int createRelationships(JsonNode relationships, Map<String, UUID> idByRowId) {
|
||||||
|
int created = 0;
|
||||||
|
for (JsonNode node : relationships) {
|
||||||
|
// Trap: a relationship node's personId / relatedPersonId fields carry the tree's
|
||||||
|
// local rowId (e.g. "row_a"), NOT a person slug. They are resolved through
|
||||||
|
// idByRowId to the upserted person's UUID.
|
||||||
|
UUID person = idByRowId.get(text(node, "personId"));
|
||||||
|
UUID related = idByRowId.get(text(node, "relatedPersonId"));
|
||||||
|
if (person == null || related == null) {
|
||||||
|
log.warn("Skipping tree relationship with unresolved rowId: {} -> {}",
|
||||||
|
text(node, "personId"), text(node, "relatedPersonId"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (addRelationshipIdempotently(person, related, text(node, "type"))) {
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean addRelationshipIdempotently(UUID person, UUID related, String type) {
|
||||||
|
try {
|
||||||
|
relationshipService.addRelationship(person,
|
||||||
|
new CreateRelationshipRequest(related, RelationType.valueOf(type), null, null, null));
|
||||||
|
return true;
|
||||||
|
} catch (DomainException e) {
|
||||||
|
if (e.getCode() == ErrorCode.DUPLICATE_RELATIONSHIP
|
||||||
|
|| e.getCode() == ErrorCode.CIRCULAR_RELATIONSHIP) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
return value == null || value.isNull() ? "" : value.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer intOrNull(JsonNode node, String field) {
|
||||||
|
JsonNode value = node.get(field);
|
||||||
|
return value == null || value.isNull() ? null : value.asInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
|
import org.raddatz.familienarchiv.tag.TagService;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads {@code canonical-tag-tree.xlsx} into the tag domain via {@link TagService},
|
||||||
|
* upserting each tag by its canonical {@code tag_path} (the source_ref). Parent links are
|
||||||
|
* resolved by the parent's path, which is the child path with its last {@code /segment}
|
||||||
|
* stripped. Rows are emitted parents-first by the normalizer, so a parent is always
|
||||||
|
* resolved before any child references it.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class TagTreeImporter {
|
||||||
|
|
||||||
|
static final List<String> REQUIRED_HEADERS = List.of("tag_path", "parent_name", "tag_name");
|
||||||
|
private static final String PATH_SEPARATOR = "/";
|
||||||
|
|
||||||
|
private final TagService tagService;
|
||||||
|
|
||||||
|
public int load(File artifact) {
|
||||||
|
List<CanonicalSheetReader.Row> rows = CanonicalSheetReader.readRows(artifact, REQUIRED_HEADERS);
|
||||||
|
Map<String, UUID> idByPath = new HashMap<>();
|
||||||
|
int processed = 0;
|
||||||
|
for (CanonicalSheetReader.Row row : rows) {
|
||||||
|
String path = row.get("tag_path");
|
||||||
|
if (path.isBlank()) continue;
|
||||||
|
UUID parentId = resolveParentId(path, idByPath);
|
||||||
|
Tag tag = tagService.upsertBySourceRef(path, row.get("tag_name"), parentId);
|
||||||
|
idByPath.put(path, tag.getId());
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
log.info("Imported {} tags from {}", processed, artifact.getName());
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID resolveParentId(String path, Map<String, UUID> idByPath) {
|
||||||
|
int lastSeparator = path.lastIndexOf(PATH_SEPARATOR);
|
||||||
|
if (lastSeparator < 0) return null;
|
||||||
|
String parentPath = path.substring(0, lastSeparator);
|
||||||
|
return idByPath.get(parentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
|
||||||
|
|
||||||
import javax.xml.parsers.DocumentBuilderFactory;
|
|
||||||
import javax.xml.parsers.ParserConfigurationException;
|
|
||||||
|
|
||||||
class XxeSafeXmlParser {
|
|
||||||
|
|
||||||
private XxeSafeXmlParser() {}
|
|
||||||
|
|
||||||
static DocumentBuilderFactory hardenedFactory() throws ParserConfigurationException {
|
|
||||||
var factory = DocumentBuilderFactory.newInstance();
|
|
||||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
|
||||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
|
||||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
|
||||||
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
|
||||||
factory.setXIncludeAware(false);
|
|
||||||
factory.setExpandEntityReferences(false);
|
|
||||||
return factory;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.person;
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
@@ -9,6 +10,9 @@ import org.raddatz.familienarchiv.user.DisplayNameFormatter;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
|
||||||
|
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "persons")
|
@Table(name = "persons")
|
||||||
@Data
|
@Data
|
||||||
@@ -53,6 +57,18 @@ public class Person {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private boolean familyMember = false;
|
private boolean familyMember = false;
|
||||||
|
|
||||||
|
// The normalizer person_id — join key and re-import idempotency key. Null for manually
|
||||||
|
// created persons; unique among non-null values (see ADR-025).
|
||||||
|
@Column(name = "source_ref")
|
||||||
|
private String sourceRef;
|
||||||
|
|
||||||
|
// A provisional person is one the importer inferred but could not confidently identify.
|
||||||
|
// Distinct from familyMember (a genealogical fact); set true only by the importer (Phase 3).
|
||||||
|
@Column(name = "provisional", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private boolean provisional = false;
|
||||||
|
|
||||||
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
|
// Entity-graph navigation for JPA JOIN queries (e.g. DocumentSpecifications.hasText).
|
||||||
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
// Uses entity relationship rather than cross-domain repository access, avoiding a
|
||||||
// separate DB roundtrip while respecting domain boundaries.
|
// separate DB roundtrip while respecting domain boundaries.
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/persons")
|
@RequestMapping("/api/persons")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Validated
|
||||||
public class PersonController {
|
public class PersonController {
|
||||||
|
|
||||||
private final PersonService personService;
|
private final PersonService personService;
|
||||||
@@ -35,15 +38,37 @@ public class PersonController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(Permission.READ_ALL)
|
@RequirePermission(Permission.READ_ALL)
|
||||||
public ResponseEntity<List<PersonSummaryDTO>> getPersons(
|
public ResponseEntity<PersonSearchResult> getPersons(
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
@RequestParam(required = false, defaultValue = "0") int size,
|
@RequestParam(required = false) PersonType type,
|
||||||
@RequestParam(required = false) String sort) {
|
@RequestParam(required = false) Boolean familyOnly,
|
||||||
if ("documentCount".equals(sort) && size > 0 && q == null) {
|
@RequestParam(required = false) Boolean hasDocuments,
|
||||||
|
@RequestParam(required = false) Boolean provisional,
|
||||||
|
// review=true reveals the import noise (transcriber view); absent/false keeps the
|
||||||
|
// clean reader default (familyMember OR documentCount > 0). The explicit filters AND
|
||||||
|
// within whichever base the review flag selects.
|
||||||
|
@RequestParam(required = false, defaultValue = "false") boolean review,
|
||||||
|
@RequestParam(required = false) String sort,
|
||||||
|
@RequestParam(defaultValue = "0") @Min(0) int page,
|
||||||
|
@RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
|
||||||
|
// Legacy top-N-by-document-count path (reader dashboard): preserved, wrapped in the
|
||||||
|
// same envelope so /api/persons always returns one shape. It is explicitly NON-paged —
|
||||||
|
// the top-N query returns the complete result, so PersonSearchResult.topN reports an
|
||||||
|
// honest totalElements (= returned count) instead of pretending to be a page slice.
|
||||||
|
if ("documentCount".equals(sort) && q == null) {
|
||||||
int safeSize = Math.min(size, 50);
|
int safeSize = Math.min(size, 50);
|
||||||
return ResponseEntity.ok(personService.findTopByDocumentCount(safeSize));
|
List<PersonSummaryDTO> top = personService.findTopByDocumentCount(safeSize);
|
||||||
|
return ResponseEntity.ok(PersonSearchResult.topN(top));
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(personService.findAll(q));
|
|
||||||
|
PersonFilter filter = PersonFilter.builder()
|
||||||
|
.type(type)
|
||||||
|
.familyOnly(familyOnly)
|
||||||
|
.hasDocuments(hasDocuments)
|
||||||
|
.provisional(provisional)
|
||||||
|
.readerDefault(!review)
|
||||||
|
.build();
|
||||||
|
return ResponseEntity.ok(personService.search(filter, page, size, q));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
@@ -110,6 +135,21 @@ public class PersonController {
|
|||||||
personService.mergePersons(id, UUID.fromString(targetIdStr));
|
personService.mergePersons(id, UUID.fromString(targetIdStr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dedicated state transition that clears the provisional flag. A separate verb (not a
|
||||||
|
// mass-assignable DTO field) so provisional can never be smuggled in via create/update.
|
||||||
|
@PatchMapping("/{id}/confirm")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public ResponseEntity<Person> confirmPerson(@PathVariable UUID id) {
|
||||||
|
return ResponseEntity.ok(personService.confirmPerson(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public void deletePerson(@PathVariable UUID id) {
|
||||||
|
personService.deletePerson(id);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Alias endpoints ────────────────────────────────────────────────────
|
// ─── Alias endpoints ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@GetMapping("/{id}/aliases")
|
@GetMapping("/{id}/aliases")
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reader/triage filter set for the persons directory, threaded as one value through
|
||||||
|
* {@code PersonController -> PersonService -> PersonRepository}. Each field is nullable:
|
||||||
|
* null means "do not constrain on this dimension".
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code type} — restrict to a single {@link PersonType}.</li>
|
||||||
|
* <li>{@code familyOnly} — when true, only {@code familyMember} persons.</li>
|
||||||
|
* <li>{@code hasDocuments} — when true, only persons with documentCount > 0.</li>
|
||||||
|
* <li>{@code provisional} — match the {@code Person.provisional} flag exactly.</li>
|
||||||
|
* <li>{@code readerDefault} — when true, restrict to {@code familyMember OR documentCount > 0}
|
||||||
|
* (the clean reader view). The explicit filters above AND with this restriction.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Builder
|
||||||
|
public record PersonFilter(
|
||||||
|
PersonType type,
|
||||||
|
Boolean familyOnly,
|
||||||
|
Boolean hasDocuments,
|
||||||
|
Boolean provisional,
|
||||||
|
boolean readerDefault
|
||||||
|
) {
|
||||||
|
/** The unconstrained "show all" filter (transcriber view, no reader restriction). */
|
||||||
|
public static PersonFilter showAll() {
|
||||||
|
return PersonFilter.builder().readerDefault(false).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The clean reader default: familyMember OR documentCount > 0, no other constraints. */
|
||||||
|
public static PersonFilter cleanDefault() {
|
||||||
|
return PersonFilter.builder().readerDefault(true).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Lookup by full alias string, used during ODS mass import
|
// Lookup by full alias string, used during ODS mass import
|
||||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||||
|
|
||||||
|
// Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3).
|
||||||
|
Optional<Person> findBySourceRef(String sourceRef);
|
||||||
|
|
||||||
// Exact first+last name match, used for filename-based sender lookup
|
// Exact first+last name match, used for filename-based sender lookup
|
||||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
||||||
|
|
||||||
@@ -41,7 +44,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
p.family_member AS familyMember,
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
@@ -54,7 +57,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
p.family_member AS familyMember,
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
@@ -63,7 +66,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member
|
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member, p.provisional
|
||||||
ORDER BY p.last_name ASC, p.first_name ASC
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
""",
|
""",
|
||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
@@ -75,7 +78,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
p.person_type AS personType,
|
p.person_type AS personType,
|
||||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
p.family_member AS familyMember,
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
FROM persons p
|
FROM persons p
|
||||||
@@ -85,6 +88,61 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
nativeQuery = true)
|
nativeQuery = true)
|
||||||
List<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit);
|
List<PersonSummaryDTO> findTopByDocumentCount(@Param("limit") int limit);
|
||||||
|
|
||||||
|
// --- #667: filter-aware paged directory ---
|
||||||
|
//
|
||||||
|
// The slice query and the count query below MUST keep an IDENTICAL WHERE clause so the
|
||||||
|
// rendered page and totalElements can never drift. Every filter is nullable: a null param
|
||||||
|
// disables that predicate via the `:param IS NULL OR …` idiom. `readerDefault` (a plain
|
||||||
|
// boolean) restricts to "familyMember OR has documents"; the explicit filters AND on top.
|
||||||
|
// documentCount is recomputed inline (not via the SELECT alias) because WHERE cannot
|
||||||
|
// reference a computed alias. All params are named — no string concatenation, no injection.
|
||||||
|
String FILTER_WHERE = """
|
||||||
|
WHERE (CAST(:type AS text) IS NULL OR p.person_type = CAST(:type AS text))
|
||||||
|
AND (:familyOnly = FALSE OR :familyOnly IS NULL OR p.family_member = TRUE)
|
||||||
|
AND (:hasDocuments = FALSE OR :hasDocuments IS NULL OR (
|
||||||
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id)) > 0)
|
||||||
|
AND (:provisional IS NULL OR p.provisional = :provisional)
|
||||||
|
AND (:readerDefault = FALSE OR (
|
||||||
|
p.family_member = TRUE OR (
|
||||||
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id)) > 0))
|
||||||
|
AND (CAST(:query AS text) IS NULL OR
|
||||||
|
LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%'))
|
||||||
|
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%'))
|
||||||
|
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%')))
|
||||||
|
""";
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
|
p.person_type AS personType,
|
||||||
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
|
p.family_member AS familyMember, p.provisional AS provisional,
|
||||||
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
|
FROM persons p
|
||||||
|
""" + FILTER_WHERE + """
|
||||||
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
""",
|
||||||
|
nativeQuery = true)
|
||||||
|
List<PersonSummaryDTO> findByFilter(@Param("type") String type,
|
||||||
|
@Param("familyOnly") Boolean familyOnly,
|
||||||
|
@Param("hasDocuments") Boolean hasDocuments,
|
||||||
|
@Param("provisional") Boolean provisional,
|
||||||
|
@Param("readerDefault") boolean readerDefault,
|
||||||
|
@Param("query") String query,
|
||||||
|
@Param("limit") int limit,
|
||||||
|
@Param("offset") int offset);
|
||||||
|
|
||||||
|
@Query(value = "SELECT COUNT(*) FROM persons p " + FILTER_WHERE, nativeQuery = true)
|
||||||
|
long countByFilter(@Param("type") String type,
|
||||||
|
@Param("familyOnly") Boolean familyOnly,
|
||||||
|
@Param("hasDocuments") Boolean hasDocuments,
|
||||||
|
@Param("provisional") Boolean provisional,
|
||||||
|
@Param("readerDefault") boolean readerDefault,
|
||||||
|
@Param("query") String query);
|
||||||
|
|
||||||
// --- Correspondent queries ---
|
// --- Correspondent queries ---
|
||||||
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
@@ -136,6 +194,12 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
|
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
|
||||||
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
|
void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
|
||||||
|
|
||||||
|
// Used by deletePerson: detach a deleted person from documents they sent, so the hard
|
||||||
|
// 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
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
INSERT INTO document_receivers (document_id, person_id)
|
INSERT INTO document_receivers (document_id, person_id)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paged result for the /api/persons list endpoint.
|
||||||
|
*
|
||||||
|
* <p>Hand-written to mirror {@code document/DocumentSearchResult} field-for-field so the
|
||||||
|
* frontend sees one paged shape across the app. Deliberately NOT Spring {@code Page<T>}
|
||||||
|
* (unstable serialized shape across Spring versions, noisy in OpenAPI) and deliberately
|
||||||
|
* NOT a reuse of the document DTO (would couple two feature modules — duplication beats
|
||||||
|
* coupling here).
|
||||||
|
*/
|
||||||
|
public record PersonSearchResult(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
List<PersonSummaryDTO> items,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
long totalElements,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
int pageNumber,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
int pageSize,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
int totalPages
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Paged factory: derives {@code totalPages} from the full match count and the page size.
|
||||||
|
* A zero count yields zero pages so the frontend hides the pagination control.
|
||||||
|
*/
|
||||||
|
public static PersonSearchResult paged(List<PersonSummaryDTO> slice, int pageNumber, int pageSize, long totalElements) {
|
||||||
|
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||||
|
return new PersonSearchResult(slice, totalElements, pageNumber, pageSize, totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-paged factory for the legacy {@code sort=documentCount} top-N dashboard path.
|
||||||
|
* That query returns the <em>complete</em> result in one shot — there is no further page
|
||||||
|
* to fetch — so the envelope reports reality rather than pretending to be a slice of a
|
||||||
|
* larger set: {@code totalElements} equals the number of rows actually returned,
|
||||||
|
* {@code pageSize} equals that same count, and {@code totalPages} is 1 (or 0 when empty).
|
||||||
|
* This avoids the earlier ambiguity where {@code totalElements} looked like a paged total.
|
||||||
|
*/
|
||||||
|
public static PersonSearchResult topN(List<PersonSummaryDTO> all) {
|
||||||
|
int count = all.size();
|
||||||
|
int totalPages = count == 0 ? 0 : 1;
|
||||||
|
return new PersonSearchResult(all, count, 0, count, totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,20 +31,55 @@ public class PersonService {
|
|||||||
private final PersonRepository personRepository;
|
private final PersonRepository personRepository;
|
||||||
private final PersonNameAliasRepository aliasRepository;
|
private final PersonNameAliasRepository aliasRepository;
|
||||||
|
|
||||||
public List<PersonSummaryDTO> findAll(String q) {
|
|
||||||
if (q == null) {
|
|
||||||
return personRepository.findAllWithDocumentCount();
|
|
||||||
}
|
|
||||||
if (q.isBlank()) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
return personRepository.searchWithDocumentCount(q.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<PersonSummaryDTO> findTopByDocumentCount(int limit) {
|
public List<PersonSummaryDTO> findTopByDocumentCount(int limit) {
|
||||||
return personRepository.findTopByDocumentCount(limit);
|
return personRepository.findTopByDocumentCount(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtered, paginated directory query. The slice and the total are derived from one
|
||||||
|
* shared WHERE clause (see {@link PersonRepository#FILTER_WHERE}) so totalElements can
|
||||||
|
* never drift from the rendered page. {@code type} is passed as the enum name because the
|
||||||
|
* native query compares against the string column.
|
||||||
|
*/
|
||||||
|
public PersonSearchResult search(PersonFilter filter, int page, int size, String q) {
|
||||||
|
String type = filter.type() == null ? null : filter.type().name();
|
||||||
|
String query = (q == null || q.isBlank()) ? null : q.trim();
|
||||||
|
int offset = page * size;
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> items = personRepository.findByFilter(
|
||||||
|
type, filter.familyOnly(), filter.hasDocuments(), filter.provisional(),
|
||||||
|
filter.readerDefault(), query, size, offset);
|
||||||
|
long total = personRepository.countByFilter(
|
||||||
|
type, filter.familyOnly(), filter.hasDocuments(), filter.provisional(),
|
||||||
|
filter.readerDefault(), query);
|
||||||
|
|
||||||
|
return PersonSearchResult.paged(items, page, size, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the {@code provisional} flag — a deliberate state transition exposed as
|
||||||
|
* {@code PATCH /api/persons/{id}/confirm}, never as a mass-assignable DTO field (CWE-915).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Person confirmPerson(UUID id) {
|
||||||
|
Person person = getById(id);
|
||||||
|
person.setProvisional(false);
|
||||||
|
return personRepository.save(person);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-deletes a person used by triage. Detaches the person from any documents they
|
||||||
|
* sent (nulls sender_id) and from any received-document references first, so the delete
|
||||||
|
* cannot orphan an FK and fail with a 500.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void deletePerson(UUID id) {
|
||||||
|
getById(id);
|
||||||
|
personRepository.reassignSenderToNull(id);
|
||||||
|
personRepository.deleteReceiverReferences(id);
|
||||||
|
personRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
public Person getById(UUID id) {
|
public Person getById(UUID id) {
|
||||||
return personRepository.findById(id)
|
return personRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||||
@@ -80,6 +115,11 @@ public class PersonService {
|
|||||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lookup by the normalizer person_id — used by the canonical importer for register-first matching. */
|
||||||
|
public Optional<Person> findBySourceRef(String sourceRef) {
|
||||||
|
return personRepository.findBySourceRef(sourceRef);
|
||||||
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person findOrCreateByAlias(String rawName) {
|
public Person findOrCreateByAlias(String rawName) {
|
||||||
@@ -115,6 +155,80 @@ public class PersonService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent upsert keyed on {@code sourceRef} (the normalizer person_id) for the
|
||||||
|
* canonical importer (Phase 3, ADR-025). On first import the canonical fields are
|
||||||
|
* written verbatim. On re-import the human-edit-preserve precedence applies:
|
||||||
|
* a non-blank existing field is never overwritten, and {@code provisional} never
|
||||||
|
* flips back to true once a human has confirmed the person.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Person upsertBySourceRef(PersonUpsertCommand cmd) {
|
||||||
|
return personRepository.findBySourceRef(cmd.sourceRef())
|
||||||
|
.map(existing -> personRepository.save(mergeCanonical(existing, cmd)))
|
||||||
|
.orElseGet(() -> fromCanonical(cmd));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Person fromCanonical(PersonUpsertCommand cmd) {
|
||||||
|
Person person = personRepository.save(Person.builder()
|
||||||
|
.sourceRef(cmd.sourceRef())
|
||||||
|
.firstName(blankToNull(cmd.firstName()))
|
||||||
|
.lastName(cmd.lastName())
|
||||||
|
.notes(blankToNull(cmd.notes()))
|
||||||
|
.birthYear(cmd.birthYear())
|
||||||
|
.deathYear(cmd.deathYear())
|
||||||
|
.familyMember(cmd.familyMember())
|
||||||
|
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
|
||||||
|
.provisional(cmd.provisional())
|
||||||
|
.build());
|
||||||
|
String maiden = blankToNull(cmd.maidenName());
|
||||||
|
if (maiden != null) {
|
||||||
|
int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1;
|
||||||
|
aliasRepository.save(PersonNameAlias.builder()
|
||||||
|
.person(person)
|
||||||
|
.lastName(maiden)
|
||||||
|
.type(PersonNameAliasType.MAIDEN_NAME)
|
||||||
|
.sortOrder(nextSortOrder)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return person;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Person mergeCanonical(Person existing, PersonUpsertCommand cmd) {
|
||||||
|
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
|
||||||
|
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
|
||||||
|
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
||||||
|
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
|
||||||
|
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
|
||||||
|
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
|
||||||
|
existing.setPersonType(cmd.personType());
|
||||||
|
}
|
||||||
|
// provisional is monotonic-downward: once it is false it never reverts to true.
|
||||||
|
// This also pins the cross-loader precedence (ADR-025): a register/tree person is
|
||||||
|
// loaded before documents and already false, so a later document row that references
|
||||||
|
// the same source_ref (provisional=true) can never flip it provisional — the guard
|
||||||
|
// below only fires while existing is still provisional. Order of document rows is
|
||||||
|
// therefore irrelevant.
|
||||||
|
if (existing.isProvisional()) {
|
||||||
|
existing.setProvisional(cmd.provisional());
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// preferHuman keeps an existing human-entered value and only falls back to the canonical
|
||||||
|
// value when the existing one is absent — the single idiom for every fill-blank field.
|
||||||
|
private static String preferHuman(String existing, String canonical) {
|
||||||
|
return (existing == null || existing.isBlank()) ? blankToNull(canonical) : existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer preferHuman(Integer existing, Integer canonical) {
|
||||||
|
return existing != null ? existing : canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String s) {
|
||||||
|
return (s == null || s.isBlank()) ? null : s.trim();
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person createPerson(String firstName, String lastName, String alias) {
|
public Person createPerson(String firstName, String lastName, String alias) {
|
||||||
Person person = Person.builder()
|
Person person = Person.builder()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public interface PersonSummaryDTO {
|
|||||||
Integer getDeathYear();
|
Integer getDeathYear();
|
||||||
String getNotes();
|
String getNotes();
|
||||||
boolean isFamilyMember();
|
boolean isFamilyMember();
|
||||||
|
boolean isProvisional();
|
||||||
long getDocumentCount();
|
long getDocumentCount();
|
||||||
|
|
||||||
default String getDisplayName() {
|
default String getDisplayName() {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importer → {@link PersonService} command for an idempotent upsert keyed on
|
||||||
|
* {@code sourceRef} (the normalizer's stable person_id). Carries only the canonical
|
||||||
|
* fields the importer owns; the service applies the human-edit-preserve precedence
|
||||||
|
* (see ADR-025): non-blank existing fields are never overwritten, and {@code provisional}
|
||||||
|
* never flips back to true once a human has confirmed a person.
|
||||||
|
*/
|
||||||
|
@Builder
|
||||||
|
public record PersonUpsertCommand(
|
||||||
|
String sourceRef,
|
||||||
|
String firstName,
|
||||||
|
String lastName,
|
||||||
|
String maidenName,
|
||||||
|
String notes,
|
||||||
|
Integer birthYear,
|
||||||
|
Integer deathYear,
|
||||||
|
boolean familyMember,
|
||||||
|
PersonType personType,
|
||||||
|
boolean provisional
|
||||||
|
) {}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package org.raddatz.familienarchiv.security;
|
package org.raddatz.familienarchiv.security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@@ -19,12 +21,22 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
|||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
|
import org.springframework.security.web.authentication.session.ChangeSessionIdAuthenticationStrategy;
|
||||||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||||
|
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||||
|
import org.springframework.security.web.csrf.CsrfException;
|
||||||
|
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
// @WebMvcTest slices do not include JacksonAutoConfiguration, so ObjectMapper
|
||||||
|
// cannot be injected here. A static instance is safe because the response
|
||||||
|
// only serializes fixed String keys — no custom naming strategy or module needed.
|
||||||
|
private static final ObjectMapper ERROR_WRITER = new ObjectMapper();
|
||||||
|
|
||||||
private final CustomUserDetailsService userDetailsService;
|
private final CustomUserDetailsService userDetailsService;
|
||||||
private final Environment environment;
|
private final Environment environment;
|
||||||
|
|
||||||
@@ -78,15 +90,13 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
// CSRF is intentionally disabled. The session model relies on:
|
// CSRF protection via CookieCsrfTokenRepository (NFR-SEC-103).
|
||||||
// 1. SameSite=Strict on the fa_session cookie — a cross-site POST from
|
// The backend sets an XSRF-TOKEN cookie (not HttpOnly so JS can read it).
|
||||||
// evil.com cannot include the cookie.
|
// All state-changing requests must include X-XSRF-TOKEN matching the cookie.
|
||||||
// 2. CORS — Spring's default rejects cross-origin requests with credentials
|
// See ADR-022 and issue #524 for the full security rationale.
|
||||||
// unless explicitly allowed (no allowedOrigins config).
|
.csrf(csrf -> csrf
|
||||||
//
|
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||||
// If either of those is ever weakened, CSRF protection MUST be re-enabled.
|
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
|
||||||
// Re-enabling CSRF (CookieCsrfTokenRepository) is planned for Phase 2 (#524).
|
|
||||||
.csrf(csrf -> csrf.disable())
|
|
||||||
|
|
||||||
.authorizeHttpRequests(auth -> {
|
.authorizeHttpRequests(auth -> {
|
||||||
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above.
|
// Actuator endpoints are governed by managementFilterChain (@Order(1)) above.
|
||||||
@@ -112,10 +122,18 @@ public class SecurityConfig {
|
|||||||
// erlaubt pdf im Iframe
|
// erlaubt pdf im Iframe
|
||||||
.headers(headers -> headers
|
.headers(headers -> headers
|
||||||
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
.frameOptions(frameOptions -> frameOptions.sameOrigin()))
|
||||||
// Return 401 (not 302 redirect to /login) for unauthenticated API requests.
|
// Return 401 for unauthenticated requests; 403+CSRF_TOKEN_MISSING for CSRF failures.
|
||||||
// httpBasic and formLogin are removed — authentication is via Spring Session only.
|
.exceptionHandling(ex -> ex
|
||||||
.exceptionHandling(ex -> ex.authenticationEntryPoint(
|
.authenticationEntryPoint(
|
||||||
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED)));
|
(req, res, e) -> res.setStatus(HttpServletResponse.SC_UNAUTHORIZED))
|
||||||
|
.accessDeniedHandler((req, res, e) -> {
|
||||||
|
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
res.setContentType("application/json;charset=UTF-8");
|
||||||
|
ErrorCode code = (e instanceof CsrfException)
|
||||||
|
? ErrorCode.CSRF_TOKEN_MISSING
|
||||||
|
: ErrorCode.FORBIDDEN;
|
||||||
|
res.getWriter().write(ERROR_WRITER.writeValueAsString(Map.of("code", code.name())));
|
||||||
|
}));
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package org.raddatz.familienarchiv.tag;
|
|||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
|
// prevents infinite recursion in JSON serialization; see ADR-022 for lazy-fetch context
|
||||||
|
@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"})
|
||||||
@Entity
|
@Entity
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@@ -27,4 +30,11 @@ public class Tag {
|
|||||||
|
|
||||||
/** Color token name (e.g. "sage"), only set on root-level tags. Null means no color. */
|
/** Color token name (e.g. "sage"), only set on root-level tags. Null means no color. */
|
||||||
private String color;
|
private String color;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import identity key, keyed on the canonical tag_path. Null for manually created tags;
|
||||||
|
* unique among non-null values. The importer (Phase 3) uses it for idempotent re-import.
|
||||||
|
*/
|
||||||
|
@Column(name = "source_ref")
|
||||||
|
private String sourceRef;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
|
|||||||
|
|
||||||
Optional<Tag> findByNameIgnoreCase(String name);
|
Optional<Tag> findByNameIgnoreCase(String name);
|
||||||
|
|
||||||
|
// Lookup by the canonical tag_path, used for idempotent canonical re-import (Phase 3).
|
||||||
|
Optional<Tag> findBySourceRef(String sourceRef);
|
||||||
|
|
||||||
List<Tag> findByNameContainingIgnoreCase(String name);
|
List<Tag> findByNameContainingIgnoreCase(String name);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import java.util.HashSet;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -49,12 +50,37 @@ public class TagService {
|
|||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lookup by the canonical tag_path — used by the canonical importer to attach a document's tag. */
|
||||||
|
public Optional<Tag> findBySourceRef(String sourceRef) {
|
||||||
|
return tagRepository.findBySourceRef(sourceRef);
|
||||||
|
}
|
||||||
|
|
||||||
public Tag findOrCreate(String name) {
|
public Tag findOrCreate(String name) {
|
||||||
String cleanName = name.trim();
|
String cleanName = name.trim();
|
||||||
return tagRepository.findByNameIgnoreCase(cleanName)
|
return tagRepository.findByNameIgnoreCase(cleanName)
|
||||||
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
|
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent upsert keyed on {@code sourceRef} (the canonical tag_path) for the
|
||||||
|
* Phase-3 importer (ADR-025). On first import the canonical name and parent are
|
||||||
|
* written; on re-import a human-renamed tag name is preserved (the source_ref is the
|
||||||
|
* stable identity, the name is a human-editable label).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Tag upsertBySourceRef(String sourceRef, String name, UUID parentId) {
|
||||||
|
return tagRepository.findBySourceRef(sourceRef)
|
||||||
|
.map(existing -> {
|
||||||
|
existing.setParentId(parentId);
|
||||||
|
return tagRepository.save(existing);
|
||||||
|
})
|
||||||
|
.orElseGet(() -> tagRepository.save(Tag.builder()
|
||||||
|
.sourceRef(sourceRef)
|
||||||
|
.name(name)
|
||||||
|
.parentId(parentId)
|
||||||
|
.build()));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Tag update(UUID id, TagUpdateDTO dto) {
|
public Tag update(UUID id, TagUpdateDTO dto) {
|
||||||
Tag tag = getById(id);
|
Tag tag = getById(id);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import org.raddatz.familienarchiv.security.Permission;
|
|||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
import org.raddatz.familienarchiv.document.DocumentService;
|
||||||
import org.raddatz.familienarchiv.document.DocumentVersionService;
|
import org.raddatz.familienarchiv.document.DocumentVersionService;
|
||||||
import org.raddatz.familienarchiv.importing.MassImportService;
|
import org.raddatz.familienarchiv.importing.CanonicalImportOrchestrator;
|
||||||
|
import org.raddatz.familienarchiv.importing.ImportStatus;
|
||||||
import org.raddatz.familienarchiv.document.ThumbnailBackfillService;
|
import org.raddatz.familienarchiv.document.ThumbnailBackfillService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -21,20 +22,20 @@ import lombok.RequiredArgsConstructor;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AdminController {
|
public class AdminController {
|
||||||
|
|
||||||
private final MassImportService massImportService;
|
private final CanonicalImportOrchestrator importOrchestrator;
|
||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
private final DocumentVersionService documentVersionService;
|
private final DocumentVersionService documentVersionService;
|
||||||
private final ThumbnailBackfillService thumbnailBackfillService;
|
private final ThumbnailBackfillService thumbnailBackfillService;
|
||||||
|
|
||||||
@PostMapping("/trigger-import")
|
@PostMapping("/trigger-import")
|
||||||
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
public ResponseEntity<ImportStatus> triggerMassImport() {
|
||||||
massImportService.runImportAsync();
|
importOrchestrator.runImportAsync();
|
||||||
return ResponseEntity.accepted().body(massImportService.getStatus());
|
return ResponseEntity.accepted().body(importOrchestrator.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/import-status")
|
@GetMapping("/import-status")
|
||||||
public ResponseEntity<MassImportService.ImportStatus> importStatus() {
|
public ResponseEntity<ImportStatus> importStatus() {
|
||||||
return ResponseEntity.ok(massImportService.getStatus());
|
return ResponseEntity.ok(importOrchestrator.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/backfill-versions")
|
@PostMapping("/backfill-versions")
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ public class InviteListItemDTO {
|
|||||||
private String status;
|
private String status;
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String shareableUrl;
|
private String shareableUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.auth.AuthService;
|
||||||
import org.raddatz.familienarchiv.user.ResetPasswordRequest;
|
import org.raddatz.familienarchiv.user.ResetPasswordRequest;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
@@ -32,6 +33,7 @@ public class PasswordResetService {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final PasswordResetTokenRepository tokenRepository;
|
private final PasswordResetTokenRepository tokenRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private JavaMailSender mailSender;
|
private JavaMailSender mailSender;
|
||||||
@@ -85,6 +87,8 @@ public class PasswordResetService {
|
|||||||
|
|
||||||
resetToken.setUsed(true);
|
resetToken.setUsed(true);
|
||||||
tokenRepository.save(resetToken);
|
tokenRepository.save(resetToken);
|
||||||
|
|
||||||
|
authService.revokeAllSessions(user.getEmail());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpSession;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
|
import org.raddatz.familienarchiv.auth.AuthService;
|
||||||
import org.raddatz.familienarchiv.user.AdminUpdateUserRequest;
|
import org.raddatz.familienarchiv.user.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.user.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.user.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.user.CreateUserRequest;
|
import org.raddatz.familienarchiv.user.CreateUserRequest;
|
||||||
@@ -26,13 +30,15 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/")
|
@RequestMapping("/api/")
|
||||||
@AllArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class UserController {
|
public class UserController {
|
||||||
private UserService userService;
|
private final UserService userService;
|
||||||
|
private final AuthService authService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
|
||||||
@GetMapping("users/me")
|
@GetMapping("users/me")
|
||||||
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {
|
public ResponseEntity<AppUser> getCurrentUser(Authentication authentication) {
|
||||||
@@ -56,9 +62,14 @@ public class UserController {
|
|||||||
@PostMapping("users/me/password")
|
@PostMapping("users/me/password")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
public void changePassword(Authentication authentication,
|
public void changePassword(Authentication authentication,
|
||||||
|
HttpSession session,
|
||||||
@RequestBody ChangePasswordDTO dto) {
|
@RequestBody ChangePasswordDTO dto) {
|
||||||
AppUser current = userService.findByEmail(authentication.getName());
|
AppUser current = userService.findByEmail(authentication.getName());
|
||||||
userService.changePassword(current.getId(), dto);
|
userService.changePassword(current.getId(), dto);
|
||||||
|
int revoked = authService.revokeOtherSessions(session.getId(), authentication.getName());
|
||||||
|
auditService.log(AuditKind.LOGOUT, current.getId(), null, Map.of(
|
||||||
|
"reason", "password_change",
|
||||||
|
"revokedCount", revoked));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("users/{id}")
|
@GetMapping("users/{id}")
|
||||||
@@ -101,6 +112,18 @@ public class UserController {
|
|||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/{id}/force-logout")
|
||||||
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
|
public ResponseEntity<Map<String, Object>> forceLogout(Authentication authentication,
|
||||||
|
@PathVariable UUID id) {
|
||||||
|
AppUser target = userService.getById(id);
|
||||||
|
int revoked = authService.revokeAllSessions(target.getEmail());
|
||||||
|
auditService.log(AuditKind.ADMIN_FORCE_LOGOUT, actorId(authentication), null, Map.of(
|
||||||
|
"targetUserId", target.getId().toString(),
|
||||||
|
"revokedCount", revoked));
|
||||||
|
return ResponseEntity.ok(Map.of("revokedCount", revoked));
|
||||||
|
}
|
||||||
|
|
||||||
private UUID actorId(Authentication auth) {
|
private UUID actorId(Authentication auth) {
|
||||||
return userService.findByEmail(auth.getName()).getId();
|
return userService.findByEmail(auth.getName()).getId();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,17 +125,10 @@ app:
|
|||||||
password: ${APP_ADMIN_PASSWORD:admin123}
|
password: ${APP_ADMIN_PASSWORD:admin123}
|
||||||
|
|
||||||
import:
|
import:
|
||||||
col:
|
# Directory holding the normalizer's committed canonical artifacts
|
||||||
index: 0
|
# (canonical-{documents,persons,tag-tree}.xlsx + canonical-persons-tree.json).
|
||||||
box: 1
|
# The loader maps columns by header name — no positional indices (see ADR-025).
|
||||||
folder: 2
|
dir: ${IMPORT_DIR:/import}
|
||||||
sender: 3
|
|
||||||
receivers: 5
|
|
||||||
date: 7
|
|
||||||
location: 9
|
|
||||||
tags: 10
|
|
||||||
summary: 11
|
|
||||||
transcription: 13
|
|
||||||
|
|
||||||
ocr:
|
ocr:
|
||||||
sender-model:
|
sender-model:
|
||||||
@@ -150,3 +143,9 @@ sentry:
|
|||||||
enable-tracing: true
|
enable-tracing: true
|
||||||
ignored-exceptions-for-type:
|
ignored-exceptions-for-type:
|
||||||
- org.raddatz.familienarchiv.exception.DomainException
|
- org.raddatz.familienarchiv.exception.DomainException
|
||||||
|
|
||||||
|
rate-limit:
|
||||||
|
login:
|
||||||
|
max-attempts-per-ip-email: 10
|
||||||
|
max-attempts-per-ip: 20
|
||||||
|
window-minutes: 15
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Repeatable migration: sets the grafana_reader role's password from the
|
||||||
|
-- ${grafanaDbPassword} placeholder (resolved by FlywayConfig from the
|
||||||
|
-- GRAFANA_DB_PASSWORD environment variable). Flyway computes the checksum on
|
||||||
|
-- the resolved migration content, so any change to GRAFANA_DB_PASSWORD changes
|
||||||
|
-- the checksum and re-applies this migration on the next boot. That makes
|
||||||
|
-- password rotation a "change env var + restart" operation — no manual psql.
|
||||||
|
--
|
||||||
|
-- V68 created the role itself (without a usable password). This file owns the
|
||||||
|
-- password lifecycle; nothing else writes it.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
EXECUTE format('ALTER ROLE grafana_reader WITH PASSWORD %L', '${grafanaDbPassword}');
|
||||||
|
END
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Read-only role used by the Grafana PostgreSQL datasource for the PO Overview
|
||||||
|
-- dashboard (issue #651). The role is created here without a usable password
|
||||||
|
-- (LOGIN-capable but no password set); R__grafana_reader_password.sql sets the
|
||||||
|
-- password from GRAFANA_DB_PASSWORD on every boot, so rotation is just "bump
|
||||||
|
-- the env var and restart the backend" — see docs/adr/024-* and the rotation
|
||||||
|
-- runbook in docs/DEPLOYMENT.md.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'grafana_reader') THEN
|
||||||
|
CREATE ROLE grafana_reader WITH LOGIN;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT CONNECT ON DATABASE ${flyway:database} TO grafana_reader;
|
||||||
|
GRANT USAGE ON SCHEMA public TO grafana_reader;
|
||||||
|
GRANT SELECT ON audit_log, documents, transcription_blocks TO grafana_reader;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
-- Phase 2 of "Handling the Unknowns": the schema foundation.
|
||||||
|
-- Consolidates every new import/precision/attribution/identity column into ONE
|
||||||
|
-- migration with a single owner so downstream phases (importer, rendering, persons
|
||||||
|
-- directory) compile against a finished, collision-free schema. See ADR-025.
|
||||||
|
--
|
||||||
|
-- This file is forward-only and immutable once shipped (Flyway checksum model):
|
||||||
|
-- any fix goes in a later version, never an edit here.
|
||||||
|
|
||||||
|
-- ─── documents: date precision, range end, raw date, raw attribution ──────────
|
||||||
|
|
||||||
|
-- Range end is only set for RANGE precision (open-ended ranges allowed → end may be null).
|
||||||
|
ALTER TABLE documents ADD COLUMN meta_date_end date;
|
||||||
|
|
||||||
|
-- Original date cell, verbatim, for provenance and "as written" display (Phase 4).
|
||||||
|
ALTER TABLE documents ADD COLUMN meta_date_raw text;
|
||||||
|
|
||||||
|
-- Raw attribution preserved even when a person is linked.
|
||||||
|
ALTER TABLE documents ADD COLUMN sender_text text;
|
||||||
|
ALTER TABLE documents ADD COLUMN receiver_text text;
|
||||||
|
|
||||||
|
-- Bound user-influenced spreadsheet text at the DB layer (mirrors transcription_blocks
|
||||||
|
-- length cap in V18). Defense in depth against malformed/huge import cells.
|
||||||
|
ALTER TABLE documents ADD CONSTRAINT chk_meta_date_raw_length CHECK (length(meta_date_raw) <= 10000);
|
||||||
|
ALTER TABLE documents ADD CONSTRAINT chk_sender_text_length CHECK (length(sender_text) <= 10000);
|
||||||
|
ALTER TABLE documents ADD CONSTRAINT chk_receiver_text_length CHECK (length(receiver_text) <= 10000);
|
||||||
|
|
||||||
|
-- Precision enum — added with a DB default of 'UNKNOWN', backfilled, then made NOT NULL.
|
||||||
|
-- The DEFAULT serves two purposes: (1) existing rows get 'UNKNOWN' immediately, and
|
||||||
|
-- (2) raw-SQL inserts that omit the column (test fixtures, ad-hoc data loads) get a sane,
|
||||||
|
-- CHECK-valid value instead of violating the NOT NULL constraint. JPA saves still set it
|
||||||
|
-- explicitly via the entity's @Builder.Default = DatePrecision.UNKNOWN.
|
||||||
|
ALTER TABLE documents ADD COLUMN meta_date_precision varchar(16) DEFAULT 'UNKNOWN';
|
||||||
|
|
||||||
|
UPDATE documents
|
||||||
|
SET meta_date_precision = CASE WHEN meta_date IS NOT NULL THEN 'DAY' ELSE 'UNKNOWN' END;
|
||||||
|
|
||||||
|
ALTER TABLE documents ALTER COLUMN meta_date_precision SET NOT NULL;
|
||||||
|
|
||||||
|
-- Fail-closed allowlist of the seven precision values (verbatim mirror of the
|
||||||
|
-- normalizer's Precision enum). The DB enforces validity independent of the Java enum.
|
||||||
|
ALTER TABLE documents ADD CONSTRAINT chk_meta_date_precision
|
||||||
|
CHECK (meta_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
||||||
|
|
||||||
|
-- A non-null range end is permitted only when precision = RANGE. A RANGE row MAY have a
|
||||||
|
-- null end (open-ended range), so the rule is one-directional, not biconditional.
|
||||||
|
ALTER TABLE documents ADD CONSTRAINT chk_meta_date_end_only_for_range
|
||||||
|
CHECK (meta_date_end IS NULL OR meta_date_precision = 'RANGE');
|
||||||
|
|
||||||
|
-- For ranges with both endpoints, the end must not precede the start.
|
||||||
|
ALTER TABLE documents ADD CONSTRAINT chk_meta_date_end_after_start
|
||||||
|
CHECK (meta_date_end IS NULL OR meta_date IS NULL OR meta_date_end >= meta_date);
|
||||||
|
|
||||||
|
-- ─── persons: source_ref (import identity) + provisional flag ─────────────────
|
||||||
|
|
||||||
|
-- The normalizer person_id: join key for documents → persons and idempotency key for
|
||||||
|
-- re-import. Nullable (manually created persons never have one); unique among non-nulls.
|
||||||
|
ALTER TABLE persons ADD COLUMN source_ref varchar(255);
|
||||||
|
CREATE UNIQUE INDEX idx_persons_source_ref ON persons (source_ref);
|
||||||
|
|
||||||
|
-- A provisional person is one the importer inferred but could not confidently identify.
|
||||||
|
-- Stays false until Phase 3 (importer) sets it; no code path writes true in this phase.
|
||||||
|
ALTER TABLE persons ADD COLUMN provisional boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- ─── tag: source_ref (import identity, keyed on canonical tag_path) ───────────
|
||||||
|
|
||||||
|
ALTER TABLE tag ADD COLUMN source_ref varchar(255);
|
||||||
|
CREATE UNIQUE INDEX idx_tag_source_ref ON tag (source_ref);
|
||||||
@@ -479,6 +479,191 @@ class MigrationIntegrationTest {
|
|||||||
assertThat(count).isEqualTo(1);
|
assertThat(count).isEqualTo(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── V69: import/precision/attribution/identity schema foundation ────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_metaDatePrecisionColumn_isNotNull() {
|
||||||
|
Integer count = jdbc.queryForObject(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'documents'
|
||||||
|
AND column_name = 'meta_date_precision'
|
||||||
|
AND is_nullable = 'NO'
|
||||||
|
""",
|
||||||
|
Integer.class);
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_backfillSql_setsDatedRowsToDayPrecision() {
|
||||||
|
// Re-run the migration's backfill UPDATE on a freshly dated row to prove the rule.
|
||||||
|
UUID docId = createDocumentWithDate("1943-05-12");
|
||||||
|
|
||||||
|
jdbc.update(V69_BACKFILL_PRECISION_SQL);
|
||||||
|
|
||||||
|
String precision = jdbc.queryForObject(
|
||||||
|
"SELECT meta_date_precision FROM documents WHERE id = ?", String.class, docId);
|
||||||
|
assertThat(precision).isEqualTo("DAY");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_backfillSql_setsUndatedRowsToUnknownPrecision() {
|
||||||
|
UUID docId = createDocument(); // no meta_date
|
||||||
|
|
||||||
|
jdbc.update(V69_BACKFILL_PRECISION_SQL);
|
||||||
|
|
||||||
|
String precision = jdbc.queryForObject(
|
||||||
|
"SELECT meta_date_precision FROM documents WHERE id = ?", String.class, docId);
|
||||||
|
assertThat(precision).isEqualTo("UNKNOWN");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the backfill UPDATE shipped in V69; idempotent for verification.
|
||||||
|
private static final String V69_BACKFILL_PRECISION_SQL = """
|
||||||
|
UPDATE documents
|
||||||
|
SET meta_date_precision = CASE WHEN meta_date IS NOT NULL THEN 'DAY' ELSE 'UNKNOWN' END
|
||||||
|
""";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_precisionCheck_rejectsValueOutsideEnum() {
|
||||||
|
UUID docId = createDocument();
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbc.update("UPDATE documents SET meta_date_precision = 'BOGUS' WHERE id = ?", docId)
|
||||||
|
).isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_metaDateEndCheck_rejectsNonNullEndWhenPrecisionNotRange() {
|
||||||
|
UUID docId = createDocumentWithDate("1943-05-12"); // precision DAY
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbc.update("UPDATE documents SET meta_date_end = '1943-06-01' WHERE id = ?", docId)
|
||||||
|
).isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_metaDateEndCheck_allowsNonNullEndWhenPrecisionRange() {
|
||||||
|
UUID docId = createDocumentWithDate("1943-05-12");
|
||||||
|
|
||||||
|
int rows = jdbc.update(
|
||||||
|
"UPDATE documents SET meta_date_precision = 'RANGE', meta_date_end = '1943-06-01' WHERE id = ?",
|
||||||
|
docId);
|
||||||
|
assertThat(rows).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_metaDateEndCheck_allowsRangeWithNullEnd() {
|
||||||
|
// Loose semantics: the normalizer may emit an open-ended RANGE (start only).
|
||||||
|
UUID docId = createDocumentWithDate("1943-05-12");
|
||||||
|
|
||||||
|
int rows = jdbc.update(
|
||||||
|
"UPDATE documents SET meta_date_precision = 'RANGE' WHERE id = ?", docId);
|
||||||
|
assertThat(rows).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_metaDateEndCheck_allowsRangeWithBothEndpointsNull() {
|
||||||
|
// Fully-open RANGE: neither start (meta_date) nor end (meta_date_end) is set.
|
||||||
|
// Both CHECKs hold (end IS NULL passes chk_meta_date_end_only_for_range; both-null
|
||||||
|
// passes chk_meta_date_end_after_start), so the row survives. This locks the actual
|
||||||
|
// DB behavior so a future tightening to a biconditional rule is a deliberate change.
|
||||||
|
UUID docId = createDocument(); // null meta_date
|
||||||
|
|
||||||
|
int rows = jdbc.update(
|
||||||
|
"UPDATE documents SET meta_date_precision = 'RANGE' WHERE id = ?", docId);
|
||||||
|
assertThat(rows).isEqualTo(1);
|
||||||
|
|
||||||
|
Object metaDate = jdbc.queryForObject("SELECT meta_date FROM documents WHERE id = ?", Object.class, docId);
|
||||||
|
Object metaDateEnd = jdbc.queryForObject(
|
||||||
|
"SELECT meta_date_end FROM documents WHERE id = ?", Object.class, docId);
|
||||||
|
assertThat(metaDate).isNull();
|
||||||
|
assertThat(metaDateEnd).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_rangeOrderCheck_rejectsEndBeforeStart() {
|
||||||
|
UUID docId = createDocumentWithDate("1943-05-12");
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbc.update(
|
||||||
|
"UPDATE documents SET meta_date_precision = 'RANGE', meta_date_end = '1943-01-01' WHERE id = ?",
|
||||||
|
docId)
|
||||||
|
).isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_metaDateRawCheck_rejectsOverlongText() {
|
||||||
|
UUID docId = createDocument();
|
||||||
|
String tooLong = "x".repeat(10001);
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbc.update("UPDATE documents SET meta_date_raw = ? WHERE id = ?", tooLong, docId)
|
||||||
|
).isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_senderTextAndReceiverText_storeRawAttribution() {
|
||||||
|
UUID docId = createDocument();
|
||||||
|
|
||||||
|
int rows = jdbc.update(
|
||||||
|
"UPDATE documents SET sender_text = 'Oma Anna', receiver_text = 'Tante Grete' WHERE id = ?",
|
||||||
|
docId);
|
||||||
|
assertThat(rows).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
void v69_personsSourceRef_uniqueIndexRejectsDuplicate() {
|
||||||
|
jdbc.update(
|
||||||
|
"INSERT INTO persons (id, last_name, source_ref) VALUES (gen_random_uuid(), 'A', 'person:dup')");
|
||||||
|
try {
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbc.update(
|
||||||
|
"INSERT INTO persons (id, last_name, source_ref) VALUES (gen_random_uuid(), 'B', 'person:dup')")
|
||||||
|
).isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
} finally {
|
||||||
|
jdbc.update("DELETE FROM persons WHERE source_ref = 'person:dup'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
void v69_personsSourceRef_allowsMultipleNulls() {
|
||||||
|
UUID a = createPerson("Null", "RefA");
|
||||||
|
UUID b = createPerson("Null", "RefB");
|
||||||
|
try {
|
||||||
|
String refA = jdbc.queryForObject("SELECT source_ref FROM persons WHERE id = ?", String.class, a);
|
||||||
|
String refB = jdbc.queryForObject("SELECT source_ref FROM persons WHERE id = ?", String.class, b);
|
||||||
|
assertThat(refA).isNull();
|
||||||
|
assertThat(refB).isNull();
|
||||||
|
} finally {
|
||||||
|
jdbc.update("DELETE FROM persons WHERE id IN (?, ?)", a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v69_personsProvisional_defaultsToFalse() {
|
||||||
|
UUID id = createPerson("Provisional", "Default");
|
||||||
|
|
||||||
|
Boolean provisional = jdbc.queryForObject(
|
||||||
|
"SELECT provisional FROM persons WHERE id = ?", Boolean.class, id);
|
||||||
|
assertThat(provisional).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||||
|
void v69_tagSourceRef_uniqueIndexRejectsDuplicate() {
|
||||||
|
jdbc.update("INSERT INTO tag (id, name, source_ref) VALUES (gen_random_uuid(), 'TagDupA', 'tag:dup')");
|
||||||
|
try {
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
jdbc.update("INSERT INTO tag (id, name, source_ref) VALUES (gen_random_uuid(), 'TagDupB', 'tag:dup')")
|
||||||
|
).isInstanceOf(DataIntegrityViolationException.class);
|
||||||
|
} finally {
|
||||||
|
jdbc.update("DELETE FROM tag WHERE source_ref = 'tag:dup'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private UUID createPerson(String firstName, String lastName) {
|
private UUID createPerson(String firstName, String lastName) {
|
||||||
@@ -504,6 +689,12 @@ class MigrationIntegrationTest {
|
|||||||
return doc.getId();
|
return doc.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UUID createDocumentWithDate(String isoDate) {
|
||||||
|
UUID id = createDocument();
|
||||||
|
jdbc.update("UPDATE documents SET meta_date = ?::date WHERE id = ?", isoDate, id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
private UUID insertAnnotation(UUID docId) {
|
private UUID insertAnnotation(UUID docId) {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
jdbc.update("""
|
jdbc.update("""
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import org.springframework.security.authentication.BadCredentialsException;
|
|||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -31,6 +30,8 @@ class AuthServiceTest {
|
|||||||
@Mock AuthenticationManager authenticationManager;
|
@Mock AuthenticationManager authenticationManager;
|
||||||
@Mock UserService userService;
|
@Mock UserService userService;
|
||||||
@Mock AuditService auditService;
|
@Mock AuditService auditService;
|
||||||
|
@Mock LoginRateLimiter loginRateLimiter;
|
||||||
|
@Mock SessionRevocationPort sessionRevocationPort;
|
||||||
@InjectMocks AuthService authService;
|
@InjectMocks AuthService authService;
|
||||||
|
|
||||||
private static final String IP = "127.0.0.1";
|
private static final String IP = "127.0.0.1";
|
||||||
@@ -129,4 +130,62 @@ class AuthServiceTest {
|
|||||||
&& !payload.containsKey("password"))
|
&& !payload.containsKey("password"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_checks_rate_limit_before_authenticating() {
|
||||||
|
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
||||||
|
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
|
||||||
|
verify(authenticationManager, never()).authenticate(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_fires_LOGIN_RATE_LIMITED_audit_when_rate_limited() {
|
||||||
|
doThrow(DomainException.tooManyRequests(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS, "rate limited"))
|
||||||
|
.when(loginRateLimiter).checkAndConsume(IP, "user@test.de");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> authService.login("user@test.de", "pass", IP, UA))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
|
||||||
|
verify(auditService).log(eq(AuditKind.LOGIN_RATE_LIMITED), isNull(), isNull(),
|
||||||
|
argThat(payload -> IP.equals(payload.get("ip")) && "user@test.de".equals(payload.get("email"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_invalidates_rate_limit_on_success() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(userId).email("user@test.de").build();
|
||||||
|
Authentication auth = new UsernamePasswordAuthenticationToken("user@test.de", null, Set.of());
|
||||||
|
when(authenticationManager.authenticate(any())).thenReturn(auth);
|
||||||
|
when(userService.findByEmail("user@test.de")).thenReturn(user);
|
||||||
|
|
||||||
|
authService.login("user@test.de", "pass123", IP, UA);
|
||||||
|
|
||||||
|
verify(loginRateLimiter).invalidateOnSuccess(IP, "user@test.de");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeOtherSessions_delegates_to_port() {
|
||||||
|
when(sessionRevocationPort.revokeOtherSessions("session-keep", "user@test.de")).thenReturn(2);
|
||||||
|
|
||||||
|
int count = authService.revokeOtherSessions("session-keep", "user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
verify(sessionRevocationPort).revokeOtherSessions("session-keep", "user@test.de");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeAllSessions_delegates_to_port() {
|
||||||
|
when(sessionRevocationPort.revokeAllSessions("user@test.de")).thenReturn(3);
|
||||||
|
|
||||||
|
int count = authService.revokeAllSessions("user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(3);
|
||||||
|
verify(sessionRevocationPort).revokeAllSessions("user@test.de");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import java.util.UUID;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
@@ -48,6 +49,7 @@ class AuthSessionControllerTest {
|
|||||||
.thenReturn(new LoginResult(appUser, auth));
|
.thenReturn(new LoginResult(appUser, auth));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}"))
|
.content("{\"email\":\"user@test.de\",\"password\":\"pass123\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -61,6 +63,7 @@ class AuthSessionControllerTest {
|
|||||||
.thenThrow(DomainException.invalidCredentials());
|
.thenThrow(DomainException.invalidCredentials());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
@@ -77,6 +80,7 @@ class AuthSessionControllerTest {
|
|||||||
|
|
||||||
// No WithMockUser — must be reachable without an active session
|
// No WithMockUser — must be reachable without an active session
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}"))
|
.content("{\"email\":\"pub@test.de\",\"password\":\"pass\"}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -91,6 +95,7 @@ class AuthSessionControllerTest {
|
|||||||
.thenReturn(new LoginResult(appUser, auth));
|
.thenReturn(new LoginResult(appUser, auth));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}"))
|
.content("{\"email\":\"fix@test.de\",\"password\":\"pass\"}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -116,6 +121,7 @@ class AuthSessionControllerTest {
|
|||||||
.thenReturn(new LoginResult(appUser, auth));
|
.thenReturn(new LoginResult(appUser, auth));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}"))
|
.content("{\"email\":\"leak@test.de\",\"password\":\"pass\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -131,12 +137,24 @@ class AuthSessionControllerTest {
|
|||||||
.thenThrow(DomainException.invalidCredentials());
|
.thenThrow(DomainException.invalidCredentials());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
.content("{\"email\":\"user@test.de\",\"password\":\"wrong\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
.andExpect(header().doesNotExist("Set-Cookie"));
|
.andExpect(header().doesNotExist("Set-Cookie"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── CSRF protection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authenticated_post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||||
|
// Red test: CSRF disabled → returns 204; after re-enabling returns 403.
|
||||||
|
mockMvc.perform(post("/api/auth/logout")
|
||||||
|
.with(user("user@test.de"))) // authenticated but no CSRF token
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── POST /api/auth/logout ─────────────────────────────────────────────────
|
// ─── POST /api/auth/logout ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -144,15 +162,18 @@ class AuthSessionControllerTest {
|
|||||||
doNothing().when(authService).logout(anyString(), anyString(), anyString());
|
doNothing().when(authService).logout(anyString(), anyString(), anyString());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/logout")
|
mockMvc.perform(post("/api/auth/logout")
|
||||||
.with(user("user@test.de")))
|
.with(user("user@test.de"))
|
||||||
|
.with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void logout_returns_401_when_not_authenticated() throws Exception {
|
void logout_without_session_returns_403() throws Exception {
|
||||||
// No authentication at all — Spring Security must return 401
|
// CsrfFilter runs before AnonymousAuthenticationFilter. When authentication is null,
|
||||||
|
// ExceptionTranslationFilter routes CSRF AccessDeniedException to accessDeniedHandler → 403.
|
||||||
mockMvc.perform(post("/api/auth/logout"))
|
mockMvc.perform(post("/api/auth/logout"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -163,7 +184,8 @@ class AuthSessionControllerTest {
|
|||||||
.when(authService).logout(anyString(), anyString(), anyString());
|
.when(authService).logout(anyString(), anyString(), anyString());
|
||||||
|
|
||||||
mockMvc.perform(post("/api/auth/logout")
|
mockMvc.perform(post("/api/auth/logout")
|
||||||
.with(user("ghost@test.de")))
|
.with(user("ghost@test.de"))
|
||||||
|
.with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ class AuthSessionIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void login_sets_opaque_fa_session_cookie() {
|
void login_sets_opaque_fa_session_cookie() {
|
||||||
ResponseEntity<String> response = doLogin();
|
String xsrf = fetchXsrfToken();
|
||||||
|
ResponseEntity<String> response = doLogin(xsrf);
|
||||||
|
|
||||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
String cookie = extractFaSessionCookie(response);
|
String cookie = extractFaSessionCookie(response);
|
||||||
@@ -73,7 +74,8 @@ class AuthSessionIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void session_cookie_authenticates_subsequent_request() {
|
void session_cookie_authenticates_subsequent_request() {
|
||||||
String cookie = extractFaSessionCookie(doLogin());
|
String xsrf = fetchXsrfToken();
|
||||||
|
String cookie = extractFaSessionCookie(doLogin(xsrf));
|
||||||
|
|
||||||
ResponseEntity<String> me = http.exchange(
|
ResponseEntity<String> me = http.exchange(
|
||||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||||
@@ -84,16 +86,17 @@ class AuthSessionIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void logout_invalidates_session_and_cookie_returns_401_on_reuse() {
|
void logout_invalidates_session_and_cookie_returns_401_on_reuse() {
|
||||||
String cookie = extractFaSessionCookie(doLogin());
|
String xsrf = fetchXsrfToken();
|
||||||
|
String sessionCookie = extractFaSessionCookie(doLogin(xsrf));
|
||||||
|
|
||||||
ResponseEntity<Void> logout = http.postForEntity(
|
ResponseEntity<Void> logout = http.postForEntity(
|
||||||
baseUrl + "/api/auth/logout",
|
baseUrl + "/api/auth/logout",
|
||||||
new HttpEntity<>(cookieHeaders(cookie)), Void.class);
|
new HttpEntity<>(csrfAndSessionHeaders(sessionCookie, xsrf)), Void.class);
|
||||||
assertThat(logout.getStatusCode().value()).isEqualTo(204);
|
assertThat(logout.getStatusCode().value()).isEqualTo(204);
|
||||||
|
|
||||||
ResponseEntity<String> me = http.exchange(
|
ResponseEntity<String> me = http.exchange(
|
||||||
baseUrl + "/api/users/me", HttpMethod.GET,
|
baseUrl + "/api/users/me", HttpMethod.GET,
|
||||||
new HttpEntity<>(cookieHeaders(cookie)), String.class);
|
new HttpEntity<>(cookieHeaders(sessionCookie)), String.class);
|
||||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +104,8 @@ class AuthSessionIntegrationTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void session_expired_by_idle_timeout_returns_401() {
|
void session_expired_by_idle_timeout_returns_401() {
|
||||||
String cookie = extractFaSessionCookie(doLogin());
|
String xsrf = fetchXsrfToken();
|
||||||
|
String cookie = extractFaSessionCookie(doLogin(xsrf));
|
||||||
|
|
||||||
// Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now
|
// Backdate LAST_ACCESS_TIME by 9 hours so lastAccess + maxInactiveInterval(8h) < now
|
||||||
long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000;
|
long nineHoursAgoMs = System.currentTimeMillis() - 9L * 3600 * 1000;
|
||||||
@@ -115,11 +119,37 @@ class AuthSessionIntegrationTest {
|
|||||||
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
assertThat(me.getStatusCode().value()).isEqualTo(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
// ─── Task: CSRF rejection at integration layer ────────────────────────────
|
||||||
|
|
||||||
private ResponseEntity<String> doLogin() {
|
@Test
|
||||||
|
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
// Deliberately omit XSRF-TOKEN cookie and X-XSRF-TOKEN header
|
||||||
|
ResponseEntity<String> response = http.postForEntity(
|
||||||
|
baseUrl + "/api/auth/logout",
|
||||||
|
new HttpEntity<>("{}", headers), String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(403);
|
||||||
|
assertThat(response.getBody()).contains("CSRF_TOKEN_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an XSRF token for use in integration tests.
|
||||||
|
* CookieCsrfTokenRepository validates that Cookie: XSRF-TOKEN=X matches X-XSRF-TOKEN: X.
|
||||||
|
* By supplying both with the same value we simulate exactly what a browser does.
|
||||||
|
*/
|
||||||
|
private String fetchXsrfToken() {
|
||||||
|
return java.util.UUID.randomUUID().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<String> doLogin(String xsrfToken) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
headers.set("Cookie", "XSRF-TOKEN=" + xsrfToken);
|
||||||
|
headers.set("X-XSRF-TOKEN", xsrfToken);
|
||||||
String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}";
|
String body = "{\"email\":\"" + TEST_EMAIL + "\",\"password\":\"" + TEST_PASSWORD + "\"}";
|
||||||
return http.postForEntity(baseUrl + "/api/auth/login",
|
return http.postForEntity(baseUrl + "/api/auth/login",
|
||||||
new HttpEntity<>(body, headers), String.class);
|
new HttpEntity<>(body, headers), String.class);
|
||||||
@@ -131,6 +161,13 @@ class AuthSessionIntegrationTest {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private HttpHeaders csrfAndSessionHeaders(String sessionId, String xsrfToken) {
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrfToken);
|
||||||
|
headers.set("X-XSRF-TOKEN", xsrfToken);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
||||||
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||||
if (setCookieHeader == null) return "";
|
if (setCookieHeader == null) return "";
|
||||||
@@ -141,6 +178,7 @@ class AuthSessionIntegrationTest {
|
|||||||
.orElse("");
|
.orElse("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private RestTemplate noThrowRestTemplate() {
|
private RestTemplate noThrowRestTemplate() {
|
||||||
RestTemplate template = new RestTemplate();
|
RestTemplate template = new RestTemplate();
|
||||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test for {@link JdbcSessionRevocationAdapter} that verifies
|
||||||
|
* session rows are actually written to / removed from the {@code spring_session}
|
||||||
|
* table backed by a real PostgreSQL container.
|
||||||
|
*
|
||||||
|
* <p>Sessions are inserted via raw JDBC to avoid the module-access restriction on
|
||||||
|
* {@code JdbcIndexedSessionRepository.JdbcSession}. The {@link SessionRevocationPort}
|
||||||
|
* bean injected here is the real {@link JdbcSessionRevocationAdapter} wired by Spring.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class JdbcSessionRevocationAdapterIntegrationTest {
|
||||||
|
|
||||||
|
@MockitoBean S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired SessionRevocationPort adapter;
|
||||||
|
@Autowired JdbcTemplate jdbcTemplate;
|
||||||
|
@Autowired TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
private static final String PRINCIPAL = "revocation-it@test.de";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clearSessions() {
|
||||||
|
// spring_session_attributes cascades on delete
|
||||||
|
transactionTemplate.execute(status -> {
|
||||||
|
jdbcTemplate.update("DELETE FROM spring_session");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helper ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a minimal {@code spring_session} row attributed to {@value #PRINCIPAL}
|
||||||
|
* and returns its opaque primary-key ID (the value the repository uses as the
|
||||||
|
* session identifier, not the {@code SESSION_ID} column which holds the public token).
|
||||||
|
*
|
||||||
|
* <p>Column layout mirrors the Flyway-managed schema shipped with the app:
|
||||||
|
* PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME, MAX_INACTIVE_INTERVAL,
|
||||||
|
* EXPIRY_TIME, PRINCIPAL_NAME.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Inserts a persisted session row for {@value #PRINCIPAL} and returns the
|
||||||
|
* {@code SESSION_ID} column value — this is the opaque identifier that
|
||||||
|
* {@link JdbcIndexedSessionRepository} uses as the session's public key
|
||||||
|
* (returned by {@code JdbcSession.getId()} and expected by
|
||||||
|
* {@link JdbcIndexedSessionRepository#deleteById}).
|
||||||
|
*
|
||||||
|
* <p>The inserts run inside a {@link TransactionTemplate} so the rows are
|
||||||
|
* committed before {@code findByPrincipalName} opens its own transaction and
|
||||||
|
* can see the data via Read Committed isolation.
|
||||||
|
*/
|
||||||
|
private String insertSession() {
|
||||||
|
String primaryId = UUID.randomUUID().toString();
|
||||||
|
// SESSION_ID is the value used by JdbcSession.getId() and findByPrincipalName map keys.
|
||||||
|
String sessionId = UUID.randomUUID().toString();
|
||||||
|
long now = Instant.now().toEpochMilli();
|
||||||
|
long expiry = now + 8L * 3600 * 1000; // 8-hour TTL
|
||||||
|
transactionTemplate.execute(status -> {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO spring_session
|
||||||
|
(PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME,
|
||||||
|
MAX_INACTIVE_INTERVAL, EXPIRY_TIME, PRINCIPAL_NAME)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
primaryId, sessionId, now, now, 28800, expiry, PRINCIPAL);
|
||||||
|
// Spring Session's listSessionsByPrincipalName query joins spring_session_attributes;
|
||||||
|
// insert a minimal attribute row so the session appears in the result set.
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO spring_session_attributes
|
||||||
|
(SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
primaryId, "test_attr", new byte[]{0});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return sessionId; // the public key used by JdbcSession.getId() and deleteById()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tests ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeAllSessions_removes_every_row_from_spring_session_table() {
|
||||||
|
insertSession();
|
||||||
|
insertSession();
|
||||||
|
|
||||||
|
int count = adapter.revokeAllSessions(PRINCIPAL);
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
assertThat(jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?",
|
||||||
|
Long.class, PRINCIPAL))
|
||||||
|
.isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeOtherSessions_deletes_non_current_rows_and_keeps_current_session() {
|
||||||
|
String keepId = insertSession();
|
||||||
|
insertSession();
|
||||||
|
insertSession();
|
||||||
|
|
||||||
|
int count = adapter.revokeOtherSessions(keepId, PRINCIPAL);
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
// The current session row must still be present (keyed by SESSION_ID)
|
||||||
|
assertThat(jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM spring_session WHERE SESSION_ID = ?",
|
||||||
|
Long.class, keepId))
|
||||||
|
.isEqualTo(1L);
|
||||||
|
// The total for this principal is now exactly 1
|
||||||
|
assertThat(jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM spring_session WHERE PRINCIPAL_NAME = ?",
|
||||||
|
Long.class, PRINCIPAL))
|
||||||
|
.isEqualTo(1L);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
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.springframework.session.jdbc.JdbcIndexedSessionRepository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class JdbcSessionRevocationAdapterTest {
|
||||||
|
|
||||||
|
@Mock JdbcIndexedSessionRepository sessionRepository;
|
||||||
|
@InjectMocks JdbcSessionRevocationAdapter adapter;
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Test
|
||||||
|
void revokeOtherSessions_preserves_current_and_deletes_N_minus_1() {
|
||||||
|
var sessions = new HashMap<String, Object>();
|
||||||
|
sessions.put("session-keep", null);
|
||||||
|
sessions.put("session-del-1", null);
|
||||||
|
sessions.put("session-del-2", null);
|
||||||
|
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
|
||||||
|
|
||||||
|
int count = adapter.revokeOtherSessions("session-keep", "user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
verify(sessionRepository, never()).deleteById("session-keep");
|
||||||
|
verify(sessionRepository).deleteById("session-del-1");
|
||||||
|
verify(sessionRepository).deleteById("session-del-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Test
|
||||||
|
void revokeAllSessions_deletes_all_sessions_for_principal() {
|
||||||
|
var sessions = new HashMap<String, Object>();
|
||||||
|
sessions.put("session-1", null);
|
||||||
|
sessions.put("session-2", null);
|
||||||
|
doReturn(sessions).when(sessionRepository).findByPrincipalName("user@test.de");
|
||||||
|
|
||||||
|
int count = adapter.revokeAllSessions("user@test.de");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
verify(sessionRepository).deleteById("session-1");
|
||||||
|
verify(sessionRepository).deleteById("session-2");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package org.raddatz.familienarchiv.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class LoginRateLimiterTest {
|
||||||
|
|
||||||
|
private LoginRateLimiter rateLimiter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
RateLimitProperties props = new RateLimitProperties();
|
||||||
|
props.setMaxAttemptsPerIpEmail(10);
|
||||||
|
props.setMaxAttemptsPerIp(20);
|
||||||
|
props.setWindowMinutes(15);
|
||||||
|
rateLimiter = new LoginRateLimiter(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tenth_attempt_from_same_ip_email_succeeds() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eleventh_attempt_from_same_ip_email_throws_TOO_MANY_LOGIN_ATTEMPTS() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocked_attempt_carries_retry_after_seconds_equal_to_window_duration() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getRetryAfterSeconds())
|
||||||
|
.isEqualTo(15 * 60L)); // windowMinutes=15 → 900 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void success_after_10_failures_resets_ip_email_bucket() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twentyfirst_attempt_from_same_ip_across_different_emails_throws() {
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user" + i + "@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "attacker@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void different_email_from_same_ip_not_blocked_by_sibling_email_exhaustion() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "other@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void email_lookup_is_case_insensitive_so_mixed_case_shares_the_same_bucket() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "User@Example.COM");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
|
||||||
|
.isEqualTo(ErrorCode.TOO_MANY_LOGIN_ATTEMPTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidateOnSuccess_is_case_insensitive_so_mixed_case_clears_the_bucket() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
rateLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimiter.invalidateOnSuccess("1.2.3.4", "User@Example.COM");
|
||||||
|
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> rateLimiter.checkAndConsume("1.2.3.4", "user@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ip_exhaustion_does_not_consume_ipEmail_tokens_for_blocked_attempts() {
|
||||||
|
// Use a tighter limiter so the phantom-consumption effect is observable.
|
||||||
|
// ipEmail=3, IP=3: exhausting IP via one email burns the other email's quota with the old code.
|
||||||
|
RateLimitProperties props = new RateLimitProperties();
|
||||||
|
props.setMaxAttemptsPerIpEmail(3);
|
||||||
|
props.setMaxAttemptsPerIp(3);
|
||||||
|
props.setWindowMinutes(15);
|
||||||
|
LoginRateLimiter tightLimiter = new LoginRateLimiter(props);
|
||||||
|
|
||||||
|
// Exhaust the per-IP bucket using "user@"
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
tightLimiter.checkAndConsume("1.2.3.4", "user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three blocked attempts for "target@" while IP is exhausted
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
assertThatThrownBy(() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful login for "user@" resets the IP bucket but NOT target@'s ipEmail bucket
|
||||||
|
tightLimiter.invalidateOnSuccess("1.2.3.4", "user@example.com");
|
||||||
|
|
||||||
|
// After IP reset: "target@" must NOT be blocked by an exhausted ipEmail bucket.
|
||||||
|
// With the old code, 3 blocked attempts burned all 3 ipEmail tokens → blocked here.
|
||||||
|
// With the fix, tokens are refunded on each blocked attempt → still has capacity.
|
||||||
|
assertThatNoException().isThrownBy(
|
||||||
|
() -> tightLimiter.checkAndConsume("1.2.3.4", "target@example.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package org.raddatz.familienarchiv.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.mock.env.MockEnvironment;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class FlywayConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveGrafanaDbPassword_throws_when_env_unset() {
|
||||||
|
FlywayConfig config = new FlywayConfig(null, new MockEnvironment());
|
||||||
|
|
||||||
|
assertThatThrownBy(config::resolveGrafanaDbPassword)
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("GRAFANA_DB_PASSWORD is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveGrafanaDbPassword_throws_when_env_blank() {
|
||||||
|
MockEnvironment env = new MockEnvironment().withProperty("GRAFANA_DB_PASSWORD", " ");
|
||||||
|
FlywayConfig config = new FlywayConfig(null, env);
|
||||||
|
|
||||||
|
assertThatThrownBy(config::resolveGrafanaDbPassword)
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.hasMessageContaining("GRAFANA_DB_PASSWORD is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolveGrafanaDbPassword_returns_value_when_env_set() {
|
||||||
|
MockEnvironment env = new MockEnvironment().withProperty("GRAFANA_DB_PASSWORD", "abc");
|
||||||
|
FlywayConfig config = new FlywayConfig(null, env);
|
||||||
|
|
||||||
|
assertThat(config.resolveGrafanaDbPassword()).isEqualTo("abc");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package org.raddatz.familienarchiv.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
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 org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
// GRAFANA_DB_PASSWORD is supplied via the global test default in
|
||||||
|
// src/test/resources/application.properties — FlywayConfig fails closed
|
||||||
|
// when it is unset, so all tests that load the migration path need it.
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||||
|
class GrafanaReaderRoleIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
// --- positive grants (SELECT on the three explicitly granted tables) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grafana_reader_has_select_on_audit_log() {
|
||||||
|
assertThat(hasPrivilege("audit_log", "SELECT")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grafana_reader_has_select_on_documents() {
|
||||||
|
assertThat(hasPrivilege("documents", "SELECT")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grafana_reader_has_select_on_transcription_blocks() {
|
||||||
|
assertThat(hasPrivilege("transcription_blocks", "SELECT")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- write-deny on the granted tables: SELECT-only means SELECT-only.
|
||||||
|
// A future migration that GRANTs INSERT/UPDATE/DELETE on any of these
|
||||||
|
// would fail these tests, even though the original positive grants still
|
||||||
|
// pass. Locks the boundary in both directions.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grafana_reader_has_no_INSERT_on_documents() {
|
||||||
|
assertThat(hasPrivilege("documents", "INSERT")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grafana_reader_has_no_UPDATE_on_audit_log() {
|
||||||
|
assertThat(hasPrivilege("audit_log", "UPDATE")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grafana_reader_has_no_DELETE_on_transcription_blocks() {
|
||||||
|
assertThat(hasPrivilege("transcription_blocks", "DELETE")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- negative grants: PII / sensitive tables MUST NOT be readable.
|
||||||
|
// The parameterized form catches the "someone widened the grant to
|
||||||
|
// ALL TABLES IN SCHEMA public" footgun — three specific positive grants
|
||||||
|
// would still pass while this sweep turns red.
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {
|
||||||
|
"app_users",
|
||||||
|
"user_groups",
|
||||||
|
"persons",
|
||||||
|
"notifications",
|
||||||
|
"document_comments",
|
||||||
|
"document_annotations",
|
||||||
|
"geschichten"
|
||||||
|
})
|
||||||
|
void grafana_reader_has_no_SELECT_on_protected_table(String table) {
|
||||||
|
assertThat(hasPrivilege(table, "SELECT")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasPrivilege(String table, String privilege) {
|
||||||
|
Boolean result = jdbc.queryForObject(
|
||||||
|
"SELECT has_table_privilege('grafana_reader', ?, ?)",
|
||||||
|
Boolean.class,
|
||||||
|
table,
|
||||||
|
privilege);
|
||||||
|
return Boolean.TRUE.equals(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,15 @@ class RateLimitInterceptorTest {
|
|||||||
verify(response).setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
verify(response).setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocked_response_includes_retry_after_header() throws Exception {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
interceptor.preHandle(request, response, null);
|
||||||
|
}
|
||||||
|
interceptor.preHandle(request, response, null);
|
||||||
|
verify(response).setHeader("Retry-After", "60");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void different_ips_have_independent_limits() throws Exception {
|
void different_ips_have_independent_limits() throws Exception {
|
||||||
HttpServletRequest other = mock(HttpServletRequest.class);
|
HttpServletRequest other = mock(HttpServletRequest.class);
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import org.springframework.security.test.context.support.WithMockUser;
|
|||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.document.DocumentSearchItem;
|
|
||||||
import org.raddatz.familienarchiv.document.SearchMatchData;
|
import org.raddatz.familienarchiv.document.SearchMatchData;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -44,10 +43,12 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(DocumentController.class)
|
@WebMvcTest(DocumentController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -128,16 +129,14 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_responseBodyItemsContainMatchData() throws Exception {
|
void search_responseBodyItemsContainMatchData() throws Exception {
|
||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
Document doc = Document.builder()
|
|
||||||
.id(docId)
|
|
||||||
.title("Brief an Anna")
|
|
||||||
.originalFilename("brief.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.build();
|
|
||||||
var matchData = new SearchMatchData(
|
var matchData = new SearchMatchData(
|
||||||
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
|
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||||
|
docId, "Brief an Anna", "brief.pdf", null, null,
|
||||||
|
DatePrecision.UNKNOWN, null, null,
|
||||||
|
List.of(), List.of(), null, null, null, null,
|
||||||
|
0, List.of(), matchData))));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -146,6 +145,28 @@ class DocumentControllerTest {
|
|||||||
.value("Er schrieb einen langen Brief"));
|
.value("Er schrieb einen langen Brief"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
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(), any()))
|
||||||
|
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
|
||||||
|
docId, "Brief an Anna", "brief.pdf", null, null,
|
||||||
|
DatePrecision.UNKNOWN, null, null,
|
||||||
|
List.of(), List.of(), null, null, null, null,
|
||||||
|
0, List.of(), matchData))));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
// flat id field present at top of item (not nested under $.items[0].document.id)
|
||||||
|
.andExpect(jsonPath("$.items[0].id").value(docId.toString()))
|
||||||
|
// sensitive storage fields must never appear in list response
|
||||||
|
.andExpect(jsonPath("$.items[0].transcription").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.items[0].filePath").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.items[0].fileHash").doesNotExist());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── /api/documents/search pagination ─────────────────────────────────────
|
// ─── /api/documents/search pagination ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -214,14 +235,14 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createDocument_returns401_whenUnauthenticated() throws Exception {
|
void createDocument_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents"))
|
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void createDocument_returns403_whenMissingWritePermission() throws Exception {
|
void createDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents"))
|
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +256,7 @@ class DocumentControllerTest {
|
|||||||
.build();
|
.build();
|
||||||
when(documentService.createDocument(any(), any())).thenReturn(doc);
|
when(documentService.createDocument(any(), any())).thenReturn(doc);
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents"))
|
mockMvc.perform(multipart("/api/documents").with(csrf()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +265,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void updateDocument_returns401_whenUnauthenticated() throws Exception {
|
void updateDocument_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +273,7 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser
|
@WithMockUser
|
||||||
void updateDocument_returns403_whenMissingWritePermission() throws Exception {
|
void updateDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID())
|
||||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,16 +290,44 @@ class DocumentControllerTest {
|
|||||||
when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc);
|
when(documentService.updateDocument(any(), any(), any(), any())).thenReturn(doc);
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/" + id)
|
mockMvc.perform(multipart("/api/documents/" + id)
|
||||||
.with(req -> { req.setMethod("PUT"); return req; }))
|
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updateDocument_bindsPrecisionFormFields_toDTO() throws Exception {
|
||||||
|
// Pins the wire contract: the edit form's metaDatePrecision / metaDateEnd /
|
||||||
|
// metaDateRaw multipart field names must bind to DocumentUpdateDTO. A rename
|
||||||
|
// on either side silently drops the precision edit; this captures the DTO.
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("Brief").originalFilename("brief.pdf").build();
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<DocumentUpdateDTO> captor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(DocumentUpdateDTO.class);
|
||||||
|
when(documentService.updateDocument(eq(id), captor.capture(), any(), any())).thenReturn(doc);
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/" + id)
|
||||||
|
.param("metaDatePrecision", "RANGE")
|
||||||
|
.param("metaDateEnd", "1917-01-11")
|
||||||
|
.param("metaDateRaw", "10.–11. Januar 1917")
|
||||||
|
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
DocumentUpdateDTO bound = captor.getValue();
|
||||||
|
org.assertj.core.api.Assertions.assertThat(bound.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
|
||||||
|
org.assertj.core.api.Assertions.assertThat(bound.getMetaDateEnd())
|
||||||
|
.isEqualTo(java.time.LocalDate.of(1917, 1, 11));
|
||||||
|
org.assertj.core.api.Assertions.assertThat(bound.getMetaDateRaw()).isEqualTo("10.–11. Januar 1917");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── DELETE /api/documents/{id} ──────────────────────────────────────────
|
// ─── DELETE /api/documents/{id} ──────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
|
void deleteDocument_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
.delete("/api/documents/" + UUID.randomUUID()))
|
.delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +335,7 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
|
void deleteDocument_returns403_whenMissingWritePermission() throws Exception {
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
.delete("/api/documents/" + UUID.randomUUID()))
|
.delete("/api/documents/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +344,7 @@ class DocumentControllerTest {
|
|||||||
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||||
.delete("/api/documents/" + id))
|
.delete("/api/documents/" + id).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,14 +352,14 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +375,7 @@ class DocumentControllerTest {
|
|||||||
org.springframework.mock.web.MockMultipartFile file =
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
||||||
.andExpect(jsonPath("$.updated").isEmpty())
|
.andExpect(jsonPath("$.updated").isEmpty())
|
||||||
@@ -345,7 +394,7 @@ class DocumentControllerTest {
|
|||||||
org.springframework.mock.web.MockMultipartFile file =
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created").isEmpty())
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
|
.andExpect(jsonPath("$.updated[0].title").value("Alter Brief"))
|
||||||
@@ -360,7 +409,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created").isEmpty())
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
.andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
|
.andExpect(jsonPath("$.errors[0].filename").value("report.docx"))
|
||||||
@@ -490,7 +539,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
|
void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
mockMvc.perform(multipart("/api/documents/quick-upload").with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created").isEmpty())
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
.andExpect(jsonPath("$.updated").isEmpty())
|
.andExpect(jsonPath("$.updated").isEmpty())
|
||||||
@@ -640,7 +689,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception {
|
void patchTrainingLabels_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -649,7 +698,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception {
|
void patchTrainingLabels_returns403_whenMissingWritePermission() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -659,7 +708,7 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchTrainingLabels_returns204_whenAddingLabel() throws Exception {
|
void patchTrainingLabels_returns204_whenAddingLabel() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
|
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
.content("{\"label\":\"KURRENT_RECOGNITION\",\"enrolled\":true}"))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
@@ -671,7 +720,7 @@ class DocumentControllerTest {
|
|||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception {
|
void patchTrainingLabels_returns204_whenRemovingLabel() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(patch("/api/documents/" + id + "/training-labels")
|
mockMvc.perform(patch("/api/documents/" + id + "/training-labels").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}"))
|
.content("{\"label\":\"KURRENT_SEGMENTATION\",\"enrolled\":false}"))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
@@ -682,7 +731,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception {
|
void patchTrainingLabels_returns400_whenUnknownLabel() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels")
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/training-labels").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}"))
|
.content("{\"label\":\"UNKNOWN_GARBAGE\",\"enrolled\":true}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -696,7 +745,7 @@ class DocumentControllerTest {
|
|||||||
org.springframework.mock.web.MockMultipartFile file =
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file))
|
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(file).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +762,7 @@ class DocumentControllerTest {
|
|||||||
org.springframework.mock.web.MockMultipartFile file =
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
|
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.id").value(id.toString()))
|
.andExpect(jsonPath("$.id").value(id.toString()))
|
||||||
.andExpect(jsonPath("$.status").value("UPLOADED"));
|
.andExpect(jsonPath("$.status").value("UPLOADED"));
|
||||||
@@ -726,7 +775,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile(
|
new org.springframework.mock.web.MockMultipartFile(
|
||||||
"file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());
|
"file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile))
|
mockMvc.perform(multipart("/api/documents/" + UUID.randomUUID() + "/file").file(htmlFile).with(csrf()))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +792,7 @@ class DocumentControllerTest {
|
|||||||
org.springframework.mock.web.MockMultipartFile file =
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
new org.springframework.mock.web.MockMultipartFile("file", "brief.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file))
|
mockMvc.perform(multipart("/api/documents/" + id + "/file").file(file).with(csrf()))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,7 +849,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created.length()").value(3))
|
.andExpect(jsonPath("$.created.length()").value(3))
|
||||||
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
|
||||||
@@ -827,7 +876,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
("{\"senderId\":\"" + senderId + "\"}").getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created").isEmpty())
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
|
||||||
@@ -859,7 +908,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
|
||||||
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
.andExpect(jsonPath("$.created[1].title").value("Beta"))
|
||||||
@@ -883,7 +932,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,7 +953,7 @@ class DocumentControllerTest {
|
|||||||
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
|
||||||
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
|
||||||
|
|
||||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata).with(csrf()))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
|
||||||
@@ -926,7 +975,7 @@ class DocumentControllerTest {
|
|||||||
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
|
||||||
}
|
}
|
||||||
|
|
||||||
mockMvc.perform(builder)
|
mockMvc.perform(builder.with(csrf()))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||||
}
|
}
|
||||||
@@ -945,7 +994,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(UUID.randomUUID().toString())))
|
.content(bulkBody(UUID.randomUUID().toString())))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -954,7 +1003,7 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void patchBulk_returns403_forReadAllUser() throws Exception {
|
void patchBulk_returns403_forReadAllUser() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(UUID.randomUUID().toString())))
|
.content(bulkBody(UUID.randomUUID().toString())))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -965,7 +1014,7 @@ class DocumentControllerTest {
|
|||||||
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"documentIds\":[]}"))
|
.content("{\"documentIds\":[]}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -976,7 +1025,7 @@ class DocumentControllerTest {
|
|||||||
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -990,7 +1039,7 @@ class DocumentControllerTest {
|
|||||||
String[] ids = new String[501];
|
String[] ids = new String[501];
|
||||||
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(ids)))
|
.content(bulkBody(ids)))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -1009,7 +1058,7 @@ class DocumentControllerTest {
|
|||||||
String tooLong = "x".repeat(256);
|
String tooLong = "x".repeat(256);
|
||||||
|
|
||||||
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
|
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -1025,7 +1074,7 @@ class DocumentControllerTest {
|
|||||||
String[] ids = new String[500];
|
String[] ids = new String[500];
|
||||||
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
|
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(ids)))
|
.content(bulkBody(ids)))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1042,7 +1091,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
// Same id sent three times — controller should dedupe and call the
|
// Same id sent three times — controller should dedupe and call the
|
||||||
// service exactly once, returning updated=1, not 3.
|
// service exactly once, returning updated=1, not 3.
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(id.toString(), id.toString(), id.toString())))
|
.content(bulkBody(id.toString(), id.toString(), id.toString())))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1061,7 +1110,7 @@ class DocumentControllerTest {
|
|||||||
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
when(documentService.applyBulkEditToDocument(any(), any(), any()))
|
||||||
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(id1.toString(), id2.toString())))
|
.content(bulkBody(id1.toString(), id2.toString())))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1137,7 +1186,7 @@ class DocumentControllerTest {
|
|||||||
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1146,7 +1195,7 @@ class DocumentControllerTest {
|
|||||||
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
|
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
|
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,7 +1204,7 @@ class DocumentControllerTest {
|
|||||||
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"ids\":[]}"))
|
.content("{\"ids\":[]}").with(csrf()))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,7 +1221,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(sb.toString()))
|
.content(sb.toString()).with(csrf()))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||||
}
|
}
|
||||||
@@ -1187,7 +1236,7 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"ids\":[\"" + id + "\"]}"))
|
.content("{\"ids\":[\"" + id + "\"]}").with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||||
.andExpect(jsonPath("$[0].title").value("Brief"))
|
.andExpect(jsonPath("$[0].title").value("Brief"))
|
||||||
@@ -1208,7 +1257,7 @@ class DocumentControllerTest {
|
|||||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
|
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
|
||||||
"evil\r\nFAKE LOG ENTRY: admin logged in"));
|
"evil\r\nFAKE LOG ENTRY: admin logged in"));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(badId.toString())))
|
.content(bulkBody(badId.toString())))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1232,7 +1281,7 @@ class DocumentControllerTest {
|
|||||||
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||||
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/bulk")
|
mockMvc.perform(patch("/api/documents/bulk").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(bulkBody(okId.toString(), badId.toString())))
|
.content(bulkBody(okId.toString(), badId.toString())))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -1337,4 +1386,16 @@ class DocumentControllerTest {
|
|||||||
DocumentStatus.REVIEWED,
|
DocumentStatus.REVIEWED,
|
||||||
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
org.raddatz.familienarchiv.tag.TagOperator.AND)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── CSRF protection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void post_without_csrf_token_returns_403_CSRF_TOKEN_MISSING() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value(ErrorCode.CSRF_TOKEN_MISSING.name()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||||
|
import org.raddatz.familienarchiv.dashboard.DashboardService;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||||
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
|
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
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.assertThatCode;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that lazy-loaded associations on {@link Document} are accessible after a service
|
||||||
|
* method returns — i.e. no {@link org.hibernate.LazyInitializationException} is thrown outside
|
||||||
|
* the Hibernate session that loaded the entity.
|
||||||
|
*
|
||||||
|
* <p><b>Known limitation:</b> calling {@code getDocumentById} (or any other service method) from
|
||||||
|
* within an already-open transaction is not covered here. When an outer transaction is active,
|
||||||
|
* the service's own {@code @Transactional} merges into it and Hibernate keeps the same session
|
||||||
|
* open, so the lazy-init guard behaves differently than in a non-transactional caller. This is a
|
||||||
|
* known constraint of the test setup, not a bug in the production code.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class DocumentLazyLoadingTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
PersonRepository personRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
TagRepository tagRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
DocumentService documentService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
DashboardService dashboardService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
AuditLogQueryService auditLogQueryService;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
documentRepository.deleteAll();
|
||||||
|
tagRepository.deleteAll();
|
||||||
|
personRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDocumentById_tagsAndReceiversAccessible_afterReturnFromService() {
|
||||||
|
Person sender = savedPerson("Max", "LzSender");
|
||||||
|
Person receiver = savedPerson("Anna", "LzReceiver");
|
||||||
|
Tag tag = savedTag("LzTag");
|
||||||
|
Document doc = savedDocument("LazyTest", "lazy_test.pdf", sender, Set.of(receiver), Set.of(tag));
|
||||||
|
|
||||||
|
Document result = documentService.getDocumentById(doc.getId());
|
||||||
|
|
||||||
|
// Only the collection access itself is in assertThatCode — guards against LazyInitializationException.
|
||||||
|
// Value assertions live outside so failures surface as AssertionError, not as unexpected exception.
|
||||||
|
assertThatCode(() -> {
|
||||||
|
result.getTags().size();
|
||||||
|
result.getReceivers().size();
|
||||||
|
}).doesNotThrowAnyException();
|
||||||
|
assertThat(result.getTags()).isNotEmpty();
|
||||||
|
result.getTags().forEach(t -> assertThat(t.getName()).isNotNull());
|
||||||
|
assertThat(result.getReceivers()).isNotEmpty();
|
||||||
|
result.getReceivers().forEach(r -> assertThat(r.getLastName()).isNotNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getRecentActivity_collectionsAccessibleAfterReturn() {
|
||||||
|
Person sender = savedPerson("Hans", "RaSender");
|
||||||
|
Tag tag = savedTag("RaTag");
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
savedDocument("RaDoc " + i, "ra_doc" + i + ".pdf", sender, Set.of(), Set.of(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Document> results = documentService.getRecentActivity(3);
|
||||||
|
|
||||||
|
// Access lazy fields inside assertThatCode — guards against LazyInitializationException.
|
||||||
|
// Value assertions live outside so failures surface as AssertionError, not as unexpected exception.
|
||||||
|
assertThatCode(() -> {
|
||||||
|
results.forEach(d -> d.getSender().getLastName());
|
||||||
|
results.forEach(d -> d.getTags().size());
|
||||||
|
}).doesNotThrowAnyException();
|
||||||
|
results.forEach(d -> assertThat(d.getSender()).isNotNull());
|
||||||
|
results.forEach(d -> assertThat(d.getSender().getLastName()).isNotNull());
|
||||||
|
results.forEach(d -> assertThat(d.getTags()).isNotEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_receiverSort_doesNotThrowLazyInitializationException() {
|
||||||
|
Person sender = savedPerson("Hans", "SrSender");
|
||||||
|
Person receiver = savedPerson("Anna", "SrReceiver");
|
||||||
|
Tag tag = savedTag("SrTag");
|
||||||
|
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,
|
||||||
|
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_senderSort_doesNotThrowLazyInitializationException() {
|
||||||
|
Person sender = savedPerson("Hans", "SsSender");
|
||||||
|
Tag tag = savedTag("SsTag");
|
||||||
|
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,
|
||||||
|
PageRequest.of(0, 20)))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dashboardService_getResume_accessesReceiversViaGetDocumentById_withoutException() {
|
||||||
|
Person sender = savedPerson("Max", "DsSender");
|
||||||
|
Person receiver = savedPerson("Anna", "DsReceiver");
|
||||||
|
Document doc = savedDocument("DashboardTest", "dashboard_test.pdf", sender, Set.of(receiver), Set.of());
|
||||||
|
UUID fakeUserId = UUID.randomUUID();
|
||||||
|
when(auditLogQueryService.findMostRecentDocumentForUser(any())).thenReturn(Optional.of(doc.getId()));
|
||||||
|
when(auditLogQueryService.findRecentContributorsPerDocument(any())).thenReturn(java.util.Map.of());
|
||||||
|
|
||||||
|
assertThatCode(() -> dashboardService.getResume(fakeUserId))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Person savedPerson(String firstName, String lastName) {
|
||||||
|
return personRepository.save(Person.builder().firstName(firstName).lastName(lastName).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Tag savedTag(String name) {
|
||||||
|
return tagRepository.save(Tag.builder().name(name).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Document savedDocument(String title, String filename, Person sender,
|
||||||
|
Set<Person> receivers, Set<Tag> tags) {
|
||||||
|
return documentRepository.save(Document.builder()
|
||||||
|
.title(title).originalFilename(filename)
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.receivers(new HashSet<>(receivers))
|
||||||
|
.tags(new HashSet<>(tags))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
||||||
|
import org.raddatz.familienarchiv.ocr.TrainingLabel;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AC #2: Document with trainingLabels does not cause LazyInitializationException in search.
|
||||||
|
* AC #3: Detail API still returns trainingLabels after the Document.list graph change.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class DocumentListItemIntegrationTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
AuditLogQueryService auditLogQueryService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
DocumentService documentService;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
documentRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_doesNotThrow_whenDocumentHasTrainingLabels() {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Kurrent Brief")
|
||||||
|
.originalFilename("kurrent.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
assertThatCode(() -> documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(0, 50)))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_returns_list_item_without_sensitive_fields_when_document_has_training_labels() {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Kurrent Brief")
|
||||||
|
.originalFilename("kurrent2.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(0, 50));
|
||||||
|
|
||||||
|
assertThat(result.totalElements()).isGreaterThan(0);
|
||||||
|
DocumentListItem item = result.items().get(0);
|
||||||
|
assertThat(item.id()).isNotNull();
|
||||||
|
assertThat(item.title()).isEqualTo("Kurrent Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_listItem_carriesMetaDatePrecisionAndEnd() {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Range Brief")
|
||||||
|
.originalFilename("range.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.documentDate(java.time.LocalDate.of(1943, 1, 1))
|
||||||
|
.metaDatePrecision(DatePrecision.RANGE)
|
||||||
|
.metaDateEnd(java.time.LocalDate.of(1943, 12, 31))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
|
null, null, null, null, null, null, null, null,
|
||||||
|
DocumentSort.DATE, "DESC", null,
|
||||||
|
PageRequest.of(0, 50));
|
||||||
|
|
||||||
|
DocumentListItem item = result.items().stream()
|
||||||
|
.filter(i -> i.title().equals("Range Brief")).findFirst().orElseThrow();
|
||||||
|
assertThat(item.metaDatePrecision()).isEqualTo(DatePrecision.RANGE);
|
||||||
|
assertThat(item.metaDateEnd()).isEqualTo(java.time.LocalDate.of(1943, 12, 31));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detail_stillReturnsTrainingLabels() {
|
||||||
|
Document saved = documentRepository.save(Document.builder()
|
||||||
|
.title("Detail Test")
|
||||||
|
.originalFilename("detail_test.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.trainingLabels(new HashSet<>(Set.of(TrainingLabel.KURRENT_RECOGNITION)))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
// Document.full entity graph (used by getDocumentById) must still load trainingLabels
|
||||||
|
Document loaded = documentService.getDocumentById(saved.getId());
|
||||||
|
|
||||||
|
assertThat(loaded.getTrainingLabels()).containsExactly(TrainingLabel.KURRENT_RECOGNITION);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package org.raddatz.familienarchiv.document;
|
package org.raddatz.familienarchiv.document;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.EntityManagerFactory;
|
||||||
|
import org.hibernate.SessionFactory;
|
||||||
|
import org.hibernate.stat.Statistics;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
@@ -21,6 +25,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -55,6 +60,12 @@ class DocumentRepositoryTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private TranscriptionBlockRepository transcriptionBlockRepository;
|
private TranscriptionBlockRepository transcriptionBlockRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EntityManagerFactory entityManagerFactory;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
// ─── save and findById ────────────────────────────────────────────────────
|
// ─── save and findById ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -490,6 +501,117 @@ class DocumentRepositoryTest {
|
|||||||
assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId());
|
assertThat(ids).containsExactlyInAnyOrder(grandparent.getId(), parent2.getId(), child2.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── query-count — entity-graph assertions ────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_withSpecAndPageable_loadsDocumentsInAtMostFiveStatements() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Hans").lastName("QcSender").build());
|
||||||
|
Person receiver = personRepository.save(Person.builder().firstName("Anna").lastName("QcReceiver").build());
|
||||||
|
Tag tag = tagRepository.save(Tag.builder().name("QcTag").build());
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("QcDoc " + i).originalFilename("qcdoc" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.receivers(new HashSet<>(Set.of(receiver)))
|
||||||
|
.tags(new HashSet<>(Set.of(tag)))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
Specification<Document> allDocs = (root, query, cb) -> null;
|
||||||
|
documentRepository.findAll(allDocs, PageRequest.of(0, 10));
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.list) must load 10 docs in ≤5 statements, not N+1")
|
||||||
|
.isLessThanOrEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findById_loadsSenderReceiversAndTagsInAtMostTwoStatements() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Max").lastName("FbSender").build());
|
||||||
|
Set<Person> receivers = new HashSet<>();
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
receivers.add(personRepository.save(
|
||||||
|
Person.builder().firstName("R" + i).lastName("FbReceiver").build()));
|
||||||
|
}
|
||||||
|
Set<Tag> tags = new HashSet<>();
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
tags.add(tagRepository.save(Tag.builder().name("FbTag" + i).build()));
|
||||||
|
}
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("FindByIdQc").originalFilename("findbyid_qc.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender).receivers(receivers).tags(tags)
|
||||||
|
.build());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
documentRepository.findById(doc.getId());
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.full) must load sender+receivers+tags in ≤2 statements, not 4")
|
||||||
|
.isLessThanOrEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_withPageable_loadsSenderWithoutNPlusOne() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Maria").lastName("RaSender").build());
|
||||||
|
Tag tag = tagRepository.save(Tag.builder().name("RaTag2").build());
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("RaDoc2 " + i).originalFilename("radoc2_" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.tags(new HashSet<>(Set.of(tag)))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
documentRepository.findAll(PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "updatedAt")));
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.list) via findAll(Pageable) must not N+1 sender for 5 docs")
|
||||||
|
.isLessThanOrEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_withSpecOnly_appliesEntityGraphInAtMostFiveStatements() {
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Otto").lastName("SoSender").build());
|
||||||
|
Tag tag = tagRepository.save(Tag.builder().name("SoTag").build());
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("SoDoc " + i).originalFilename("sodoc_" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.tags(new HashSet<>(Set.of(tag)))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
|
||||||
|
stats.setStatisticsEnabled(true);
|
||||||
|
stats.clear();
|
||||||
|
|
||||||
|
Specification<Document> allDocs = (root, query, cb) -> null;
|
||||||
|
documentRepository.findAll(allDocs);
|
||||||
|
|
||||||
|
assertThat(stats.getPrepareStatementCount())
|
||||||
|
.as("@EntityGraph(Document.list) via findAll(Spec) must not N+1 sender for 5 docs")
|
||||||
|
.isLessThanOrEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── seeding helpers ─────────────────────────────────────────────────────
|
// ─── seeding helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Document uploaded(String title) {
|
private Document uploaded(String title) {
|
||||||
|
|||||||
@@ -125,10 +125,10 @@ class DocumentSearchPagedIntegrationTest {
|
|||||||
|
|
||||||
// No document id should appear on both pages — slicing must be exclusive.
|
// No document id should appear on both pages — slicing must be exclusive.
|
||||||
var idsOnPage0 = page0.items().stream()
|
var idsOnPage0 = page0.items().stream()
|
||||||
.map(item -> item.document().getId())
|
.map(item -> item.id())
|
||||||
.toList();
|
.toList();
|
||||||
var idsOnPage1 = page1.items().stream()
|
var idsOnPage1 = page1.items().stream()
|
||||||
.map(item -> item.document().getId())
|
.map(item -> item.id())
|
||||||
.toList();
|
.toList();
|
||||||
for (UUID id : idsOnPage0) {
|
for (UUID id : idsOnPage0) {
|
||||||
assertThat(idsOnPage1).doesNotContain(id);
|
assertThat(idsOnPage1).doesNotContain(id);
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package org.raddatz.familienarchiv.document;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
import org.raddatz.familienarchiv.audit.ActivityActorDTO;
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -14,14 +12,12 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
class DocumentSearchResultTest {
|
class DocumentSearchResultTest {
|
||||||
|
|
||||||
private DocumentSearchItem item(UUID docId) {
|
private DocumentListItem item(UUID docId) {
|
||||||
Document doc = Document.builder()
|
return new DocumentListItem(
|
||||||
.id(docId)
|
docId, "Test", "test.pdf", null, null,
|
||||||
.title("Test")
|
DatePrecision.UNKNOWN, null, null,
|
||||||
.originalFilename("test.pdf")
|
List.of(), List.of(), null, null, null, null,
|
||||||
.status(DocumentStatus.UPLOADED)
|
0, List.of(), SearchMatchData.empty());
|
||||||
.build();
|
|
||||||
return new DocumentSearchItem(doc, SearchMatchData.empty(), 0, List.of());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -45,7 +41,7 @@ class DocumentSearchResultTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void paged_factory_populates_paging_fields_from_pageable_and_total() {
|
void paged_factory_populates_paging_fields_from_pageable_and_total() {
|
||||||
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
|
List<DocumentListItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
|
||||||
|
|
||||||
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
|
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
|
||||||
|
|
||||||
@@ -68,9 +64,11 @@ class DocumentSearchResultTest {
|
|||||||
void of_exposes_items_with_completion_and_contributors() {
|
void of_exposes_items_with_completion_and_contributors() {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
|
ActivityActorDTO actor = new ActivityActorDTO("AB", "#f00", "Anna Braun");
|
||||||
Document doc = Document.builder().id(id).title("T").originalFilename("t.pdf")
|
DocumentListItem item = new DocumentListItem(
|
||||||
.status(DocumentStatus.UPLOADED).build();
|
id, "T", "t.pdf", null, null,
|
||||||
DocumentSearchItem item = new DocumentSearchItem(doc, SearchMatchData.empty(), 75, List.of(actor));
|
DatePrecision.UNKNOWN, null, null,
|
||||||
|
List.of(), List.of(), null, null, null, null,
|
||||||
|
75, List.of(actor), SearchMatchData.empty());
|
||||||
|
|
||||||
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
|
DocumentSearchResult result = DocumentSearchResult.of(List.of(item));
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class DocumentServiceSortTest {
|
|||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id2); // newer first
|
assertThat(result.items().get(0).id()).isEqualTo(id2); // newer first
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
|
// ─── RELEVANCE sort — pure text (no filters) ──────────────────────────────
|
||||||
@@ -104,7 +104,7 @@ class DocumentServiceSortTest {
|
|||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -121,7 +121,7 @@ class DocumentServiceSortTest {
|
|||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
|
"Brief", null, null, null, null, null, null, null, null, null, null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
|
assertThat(result.items().get(0).id()).isEqualTo(id1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
|
// ─── RELEVANCE sort — overflow guard ─────────────────────────────────────
|
||||||
@@ -156,7 +156,7 @@ class DocumentServiceSortTest {
|
|||||||
DocumentSort.RELEVANCE, null, null, PAGE);
|
DocumentSort.RELEVANCE, null, null, PAGE);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(1);
|
assertThat(result.items()).hasSize(1);
|
||||||
assertThat(result.items().get(0).document().getId()).isEqualTo(uuidId);
|
assertThat(result.items().get(0).id()).isEqualTo(uuidId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RELEVANCE sort — text + active filter ────────────────────────────────
|
// ─── RELEVANCE sort — text + active filter ────────────────────────────────
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import org.raddatz.familienarchiv.audit.AuditLogQueryService;
|
|||||||
import org.raddatz.familienarchiv.audit.AuditService;
|
import org.raddatz.familienarchiv.audit.AuditService;
|
||||||
import org.raddatz.familienarchiv.document.annotation.AnnotationService;
|
import org.raddatz.familienarchiv.document.annotation.AnnotationService;
|
||||||
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockQueryService;
|
import org.raddatz.familienarchiv.document.transcription.TranscriptionBlockQueryService;
|
||||||
import org.raddatz.familienarchiv.document.DocumentSearchItem;
|
import org.raddatz.familienarchiv.document.DocumentListItem;
|
||||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.document.DocumentSort;
|
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||||
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.document.DocumentUpdateDTO;
|
||||||
@@ -144,6 +144,53 @@ class DocumentServiceTest {
|
|||||||
assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
|
assertThat(doc.getArchiveFolder()).isEqualTo("Mappe B");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_persistsDatePrecisionEndAndRaw() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenReturn(doc);
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setDocumentDate(LocalDate.of(1917, 1, 10));
|
||||||
|
dto.setMetaDatePrecision(DatePrecision.RANGE);
|
||||||
|
dto.setMetaDateEnd(LocalDate.of(1917, 1, 11));
|
||||||
|
dto.setMetaDateRaw("10.–11. Januar 1917");
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null, null);
|
||||||
|
|
||||||
|
assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
|
||||||
|
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11));
|
||||||
|
assertThat(doc.getMetaDateRaw()).isEqualTo("10.–11. Januar 1917");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_preservesStoredPrecision_whenDtoOmitsIt() throws Exception {
|
||||||
|
// 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.
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(id)
|
||||||
|
.metaDatePrecision(DatePrecision.MONTH)
|
||||||
|
.metaDateEnd(LocalDate.of(1916, 6, 30))
|
||||||
|
.metaDateRaw("Juni 1916")
|
||||||
|
.receivers(new HashSet<>())
|
||||||
|
.tags(new HashSet<>())
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenReturn(doc);
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setLocation("Berlin"); // unrelated edit; precision fields left null
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null, null);
|
||||||
|
|
||||||
|
assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.MONTH);
|
||||||
|
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1916, 6, 30));
|
||||||
|
assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
// ─── deleteTagCascading ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1444,7 +1491,7 @@ class DocumentServiceTest {
|
|||||||
assertThat(result.totalPages()).isEqualTo(3);
|
assertThat(result.totalPages()).isEqualTo(3);
|
||||||
assertThat(result.items()).hasSize(50);
|
assertThat(result.items()).hasSize(50);
|
||||||
// Page 1 (offset 50) under ascending sender sort should start at L050
|
// Page 1 (offset 50) under ascending sender sort should start at L050
|
||||||
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
|
assertThat(result.items().get(0).sender().getLastName()).isEqualTo("L050");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1565,7 +1612,7 @@ class DocumentServiceTest {
|
|||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).hasSize(2);
|
assertThat(result.items()).hasSize(2);
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
|
assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Has Sender", "No Sender");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
// ─── searchDocuments — RECEIVER sort, empty receivers ───────────────────────
|
||||||
@@ -1584,7 +1631,7 @@ class DocumentServiceTest {
|
|||||||
DocumentSearchResult result = documentService.searchDocuments(
|
DocumentSearchResult result = documentService.searchDocuments(
|
||||||
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||||
.containsExactly("Has Receiver", "No Receivers");
|
.containsExactly("Has Receiver", "No Receivers");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1607,7 +1654,7 @@ class DocumentServiceTest {
|
|||||||
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
|
||||||
|
|
||||||
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
// null lastName should sort to end (treated as empty), not before "smith" (as "null")
|
||||||
assertThat(result.items()).extracting(item -> item.document().getTitle())
|
assertThat(result.items()).extracting(DocumentListItem::title)
|
||||||
.containsExactly("smith doc", "Null lastname doc");
|
.containsExactly("smith doc", "Null lastname doc");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(AnnotationController.class)
|
@WebMvcTest(AnnotationController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -67,7 +68,7 @@ class AnnotationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -76,7 +77,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -92,7 +93,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -101,7 +102,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception {
|
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -133,7 +134,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -143,28 +144,28 @@ class AnnotationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ class AnnotationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void patchAnnotation_returns401_whenUnauthenticated() throws Exception {
|
void patchAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -183,7 +184,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void patchAnnotation_returns403_withoutPermission() throws Exception {
|
void patchAnnotation_returns403_withoutPermission() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -199,7 +200,7 @@ class AnnotationControllerTest {
|
|||||||
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
||||||
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
|
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -217,7 +218,7 @@ class AnnotationControllerTest {
|
|||||||
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
.x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build();
|
||||||
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId)
|
mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -229,7 +230,7 @@ class AnnotationControllerTest {
|
|||||||
when(annotationService.updateAnnotation(any(), any(), any()))
|
when(annotationService.updateAnnotation(any(), any(), any()))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found"));
|
.thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found"));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(PATCH_JSON))
|
.content(PATCH_JSON))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -238,7 +239,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception {
|
void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"x\":-0.1,\"y\":0.3}"))
|
.content("{\"x\":-0.1,\"y\":0.3}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -247,7 +248,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception {
|
void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"width\":0.005}"))
|
.content("{\"width\":0.005}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -256,7 +257,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception {
|
void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"height\":0.005}"))
|
.content("{\"height\":0.005}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -265,7 +266,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void patchAnnotation_returns400_withXAboveMaximum() throws Exception {
|
void patchAnnotation_returns400_withXAboveMaximum() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())
|
mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"x\":1.1}"))
|
.content("{\"x\":1.1}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -276,7 +277,7 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
|
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
|
||||||
// authentication == null → resolveUserId returns null
|
// authentication == null → resolveUserId returns null
|
||||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -294,7 +295,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -312,7 +313,7 @@ class AnnotationControllerTest {
|
|||||||
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(ANNOTATION_JSON))
|
.content(ANNOTATION_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(CommentController.class)
|
@WebMvcTest(CommentController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -70,7 +71,7 @@ class CommentControllerTest {
|
|||||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
||||||
@@ -79,7 +80,7 @@ class CommentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
|
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
UUID blockId = UUID.randomUUID();
|
UUID blockId = UUID.randomUUID();
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -88,7 +89,7 @@ class CommentControllerTest {
|
|||||||
@WithMockUser
|
@WithMockUser
|
||||||
void postBlockComment_returns403_whenMissingPermission() throws Exception {
|
void postBlockComment_returns403_whenMissingPermission() throws Exception {
|
||||||
UUID blockId = UUID.randomUUID();
|
UUID blockId = UUID.randomUUID();
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,7 @@ class CommentControllerTest {
|
|||||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -116,7 +117,7 @@ class CommentControllerTest {
|
|||||||
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").build();
|
||||||
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ class CommentControllerTest {
|
|||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
|
void replyToBlockComment_returns400_when_blockId_is_not_a_UUID() throws Exception {
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/NOT-A-UUID"
|
||||||
+ "/comments/" + COMMENT_ID + "/replies")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
}
|
}
|
||||||
@@ -136,7 +137,7 @@ class CommentControllerTest {
|
|||||||
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
|
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
UUID blockId = UUID.randomUUID();
|
UUID blockId = UUID.randomUUID();
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
+ "/comments/" + COMMENT_ID + "/replies")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -151,7 +152,7 @@ class CommentControllerTest {
|
|||||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
+ "/comments/" + COMMENT_ID + "/replies")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -166,7 +167,7 @@ class CommentControllerTest {
|
|||||||
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
+ "/comments/" + COMMENT_ID + "/replies")
|
+ "/comments/" + COMMENT_ID + "/replies").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
@@ -175,7 +176,7 @@ class CommentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void editComment_returns401_whenUnauthenticated() throws Exception {
|
void editComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,7 @@ class CommentControllerTest {
|
|||||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
@@ -199,7 +200,7 @@ class CommentControllerTest {
|
|||||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
@@ -208,14 +209,14 @@ class CommentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
||||||
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(TranscriptionBlockController.class)
|
@WebMvcTest(TranscriptionBlockController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -143,7 +144,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createBlock_returns401_whenUnauthenticated() throws Exception {
|
void createBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -152,7 +153,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
void createBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -164,7 +165,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock());
|
when(transcriptionService.createBlock(eq(DOC_ID), any(), any())).thenReturn(sampleBlock());
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -177,7 +178,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
void createBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(null);
|
when(userService.findByEmail(any())).thenReturn(null);
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(CREATE_JSON))
|
.content(CREATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -192,7 +193,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
+ "\"mentionedPersons\":[{\"personId\":\"" + UUID.randomUUID()
|
||||||
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
+ "\",\"displayName\":\"" + longName + "\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -206,7 +207,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
String body = "{\"pageNumber\":1,\"x\":0.1,\"y\":0.2,\"width\":0.3,\"height\":0.4,\"text\":\"x\","
|
||||||
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
+ "\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(post(URL_BASE)
|
mockMvc.perform(post(URL_BASE).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -217,7 +218,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateBlock_returns401_whenUnauthenticated() throws Exception {
|
void updateBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -226,7 +227,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
void updateBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -243,7 +244,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any()))
|
when(transcriptionService.updateBlock(eq(DOC_ID), eq(BLOCK_ID), any(), any()))
|
||||||
.thenReturn(updated);
|
.thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -259,7 +260,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":\""
|
||||||
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
+ UUID.randomUUID() + "\",\"displayName\":\"" + longName + "\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -272,7 +273,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(userService.findByEmail(any())).thenReturn(mockUser());
|
when(userService.findByEmail(any())).thenReturn(mockUser());
|
||||||
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
String body = "{\"text\":\"x\",\"mentionedPersons\":[{\"personId\":null,\"displayName\":\"Auguste Raddatz\"}]}";
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(body))
|
.content(body))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -286,7 +287,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.updateBlock(any(), any(), any(), any()))
|
when(transcriptionService.updateBlock(any(), any(), any(), any()))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"));
|
.thenThrow(DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"));
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -297,7 +298,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
void updateBlock_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(null);
|
when(userService.findByEmail(any())).thenReturn(null);
|
||||||
|
|
||||||
mockMvc.perform(put(URL_BLOCK)
|
mockMvc.perform(put(URL_BLOCK).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(UPDATE_JSON))
|
.content(UPDATE_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -307,28 +308,28 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteBlock_returns401_whenUnauthenticated() throws Exception {
|
void deleteBlock_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
void deleteBlock_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
void deleteBlock_returns204_whenAuthorised() throws Exception {
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +340,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"))
|
DomainException.notFound(ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND, "not found"))
|
||||||
.when(transcriptionService).deleteBlock(any(), any());
|
.when(transcriptionService).deleteBlock(any(), any());
|
||||||
|
|
||||||
mockMvc.perform(delete(URL_BLOCK))
|
mockMvc.perform(delete(URL_BLOCK).with(csrf()))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +348,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
|
void reorderBlocks_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_REORDER)
|
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -356,7 +357,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
|
void reorderBlocks_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(put(URL_REORDER)
|
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -367,7 +368,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
void reorderBlocks_returns200_withReorderedBlocks_whenAuthorised() throws Exception {
|
||||||
when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock()));
|
when(transcriptionService.listBlocks(DOC_ID)).thenReturn(List.of(sampleBlock()));
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REORDER)
|
mockMvc.perform(put(URL_REORDER).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(REORDER_JSON))
|
.content(REORDER_JSON))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -434,7 +435,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed);
|
when(transcriptionService.reviewBlock(eq(DOC_ID), eq(BLOCK_ID), any())).thenReturn(reviewed);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
|
mockMvc.perform(put("/api/documents/{documentId}/transcription-blocks/{blockId}/review",
|
||||||
DOC_ID, BLOCK_ID))
|
DOC_ID, BLOCK_ID).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.reviewed").value(true));
|
.andExpect(jsonPath("$.reviewed").value(true));
|
||||||
}
|
}
|
||||||
@@ -445,14 +446,14 @@ class TranscriptionBlockControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +470,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||||
.thenReturn(List.of(b1, b2));
|
.thenReturn(List.of(b1, b2));
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray())
|
.andExpect(jsonPath("$").isArray())
|
||||||
.andExpect(jsonPath("$[0].reviewed").value(true))
|
.andExpect(jsonPath("$[0].reviewed").value(true))
|
||||||
@@ -483,7 +484,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
|
||||||
.thenReturn(List.of());
|
.thenReturn(List.of());
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$").isArray())
|
.andExpect(jsonPath("$").isArray())
|
||||||
.andExpect(jsonPath("$").isEmpty());
|
.andExpect(jsonPath("$").isEmpty());
|
||||||
@@ -494,7 +495,7 @@ class TranscriptionBlockControllerTest {
|
|||||||
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
|
||||||
when(userService.findByEmail(any())).thenReturn(null);
|
when(userService.findByEmail(any())).thenReturn(null);
|
||||||
|
|
||||||
mockMvc.perform(put(URL_REVIEW_ALL))
|
mockMvc.perform(put(URL_REVIEW_ALL).with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(GeschichteController.class)
|
@WebMvcTest(GeschichteController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -130,7 +131,7 @@ class GeschichteControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void create_returns401_whenUnauthenticated() throws Exception {
|
void create_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/geschichten")
|
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"title\":\"x\"}"))
|
.content("{\"title\":\"x\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -139,7 +140,7 @@ class GeschichteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void create_returns403_whenLackingBlogWrite() throws Exception {
|
void create_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
mockMvc.perform(post("/api/geschichten")
|
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"title\":\"x\"}"))
|
.content("{\"title\":\"x\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -155,7 +156,7 @@ class GeschichteControllerTest {
|
|||||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||||
dto.setTitle("New");
|
dto.setTitle("New");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/geschichten")
|
mockMvc.perform(post("/api/geschichten").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -167,7 +168,7 @@ class GeschichteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void update_returns403_whenLackingBlogWrite() throws Exception {
|
void update_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID())
|
mockMvc.perform(patch("/api/geschichten/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -180,7 +181,7 @@ class GeschichteControllerTest {
|
|||||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||||
.thenReturn(published(id, "Updated"));
|
.thenReturn(published(id, "Updated"));
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/geschichten/{id}", id)
|
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"status\":\"PUBLISHED\"}"))
|
.content("{\"status\":\"PUBLISHED\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -192,7 +193,7 @@ class GeschichteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void delete_returns403_whenLackingBlogWrite() throws Exception {
|
void delete_returns403_whenLackingBlogWrite() throws Exception {
|
||||||
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()))
|
mockMvc.perform(delete("/api/geschichten/{id}", UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +202,7 @@ class GeschichteControllerTest {
|
|||||||
void delete_returns204_withBlogWrite() throws Exception {
|
void delete_returns204_withBlogWrite() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/geschichten/{id}", id))
|
mockMvc.perform(delete("/api/geschichten/{id}", id).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(geschichteService).delete(id);
|
verify(geschichteService).delete(id);
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
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.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||||
|
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||||
|
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.test.util.ReflectionTestUtils;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real Postgres (Testcontainers) integration test for the canonical importer. The
|
||||||
|
* {@code UNIQUE(source_ref)} constraint and the upsert-on-conflict behaviour only exist
|
||||||
|
* in real Postgres (never H2), so idempotency is verified here. S3 is mocked — the
|
||||||
|
* synthetic document rows carry no on-disk files, so every document is a PLACEHOLDER and
|
||||||
|
* no upload is attempted.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class CanonicalImportIntegrationTest {
|
||||||
|
|
||||||
|
@MockitoBean S3Client s3Client;
|
||||||
|
|
||||||
|
@Autowired CanonicalImportOrchestrator orchestrator;
|
||||||
|
@Autowired PersonRepository personRepository;
|
||||||
|
@Autowired TagRepository tagRepository;
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
Path artifactDir;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
documentRepository.deleteAll();
|
||||||
|
personRepository.deleteAll();
|
||||||
|
tagRepository.deleteAll();
|
||||||
|
artifactDir = Files.createTempDirectory("canonical-import-it");
|
||||||
|
writeArtifacts(artifactDir);
|
||||||
|
ReflectionTestUtils.setField(orchestrator, "canonicalDir", artifactDir.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The import commits through its own transactions (the orchestrator is not transactional),
|
||||||
|
* so this test cannot rely on {@code @Transactional} rollback for isolation. Delete the
|
||||||
|
* committed rows after each test — otherwise the last test's documents (dated 1888-02) and
|
||||||
|
* persons/tags leak into the shared Testcontainers Postgres and pollute other integration
|
||||||
|
* tests that assume a known seed (e.g. DocumentDensityIntegrationTest,
|
||||||
|
* DocumentSearchPagedIntegrationTest). Mirrors the @AfterEach deleteAll convention used by
|
||||||
|
* DocumentListItemIntegrationTest.
|
||||||
|
*/
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
documentRepository.deleteAll();
|
||||||
|
personRepository.deleteAll();
|
||||||
|
tagRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reimport_isIdempotent_noDuplicatePersonsTagsOrDocuments() {
|
||||||
|
orchestrator.runImport();
|
||||||
|
long personsAfterFirst = personRepository.count();
|
||||||
|
long tagsAfterFirst = tagRepository.count();
|
||||||
|
long documentsAfterFirst = documentRepository.count();
|
||||||
|
assertThat(orchestrator.getStatus().state()).isEqualTo(ImportStatus.State.DONE);
|
||||||
|
assertThat(personsAfterFirst).isPositive();
|
||||||
|
assertThat(tagsAfterFirst).isPositive();
|
||||||
|
assertThat(documentsAfterFirst).isPositive();
|
||||||
|
|
||||||
|
orchestrator.runImport();
|
||||||
|
|
||||||
|
assertThat(personRepository.count()).isEqualTo(personsAfterFirst);
|
||||||
|
assertThat(tagRepository.count()).isEqualTo(tagsAfterFirst);
|
||||||
|
assertThat(documentRepository.count()).isEqualTo(documentsAfterFirst);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reimport_preservesHumanEditedPersonField() {
|
||||||
|
orchestrator.runImport();
|
||||||
|
Person walter = personRepository.findBySourceRef("de-gruyter-walter").orElseThrow();
|
||||||
|
walter.setNotes("Verified by archivist");
|
||||||
|
walter.setFirstName("Walther");
|
||||||
|
personRepository.save(walter);
|
||||||
|
|
||||||
|
orchestrator.runImport();
|
||||||
|
|
||||||
|
Person reimported = personRepository.findBySourceRef("de-gruyter-walter").orElseThrow();
|
||||||
|
assertThat(reimported.getNotes()).isEqualTo("Verified by archivist");
|
||||||
|
assertThat(reimported.getFirstName()).isEqualTo("Walther");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void import_linksDocumentSenderToRegisterPerson_andRetainsRawText() {
|
||||||
|
orchestrator.runImport();
|
||||||
|
|
||||||
|
Person walter = personRepository.findBySourceRef("de-gruyter-walter").orElseThrow();
|
||||||
|
Document doc = documentRepository.findByOriginalFilename("W-0001").orElseThrow();
|
||||||
|
assertThat(doc.getSender()).isNotNull();
|
||||||
|
assertThat(doc.getSender().getId()).isEqualTo(walter.getId());
|
||||||
|
assertThat(doc.getSenderText()).isEqualTo("Walter de Gruyter");
|
||||||
|
assertThat(doc.getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void import_provisionalFlag_trueForImporterCreated_falseForRegister() {
|
||||||
|
orchestrator.runImport();
|
||||||
|
|
||||||
|
Optional<Person> register = personRepository.findBySourceRef("de-gruyter-walter");
|
||||||
|
assertThat(register).get().extracting(Person::isProvisional).isEqualTo(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reimport_prunesRemovedReceiverAndTag_whenCanonicalRowShrinks() throws Exception {
|
||||||
|
orchestrator.runImport();
|
||||||
|
// findById uses the Document.full entity graph so receivers/tags initialise eagerly.
|
||||||
|
Document before = documentRepository.findById(
|
||||||
|
documentRepository.findByOriginalFilename("W-0001").orElseThrow().getId()).orElseThrow();
|
||||||
|
assertThat(before.getReceivers()).isNotEmpty();
|
||||||
|
assertThat(before.getTags()).isNotEmpty();
|
||||||
|
|
||||||
|
// Re-stage the document sheet with W-0001's receiver and tag removed.
|
||||||
|
writeSheet(artifactDir.resolve("canonical-documents.xlsx"),
|
||||||
|
List.of("index", "file", "sender_person_id", "sender_name", "receiver_person_ids",
|
||||||
|
"receiver_names", "date_iso", "date_raw", "date_precision", "date_end", "location", "tags", "summary"),
|
||||||
|
List.of(
|
||||||
|
List.of("W-0001", "", "de-gruyter-walter", "Walter de Gruyter",
|
||||||
|
"", "", "1888-02-15", "15.2.1888", "DAY", "", "Rotterdam", "", "Geschäftsreise"),
|
||||||
|
List.of("W-0002", "", "de-gruyter-eugenie", "Eugenie de Gruyter",
|
||||||
|
"de-gruyter-walter", "Walter de Gruyter", "1888-02-16", "16.2.1888", "DAY", "",
|
||||||
|
"Middelburg", "Themen/Brautbriefe", "Reisepläne")));
|
||||||
|
|
||||||
|
orchestrator.runImport();
|
||||||
|
|
||||||
|
Document after = documentRepository.findById(before.getId()).orElseThrow();
|
||||||
|
assertThat(after.getReceivers()).isEmpty();
|
||||||
|
assertThat(after.getTags()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void import_neverFlipsRegisterPersonToProvisional_whenReferencedByDocumentRow() {
|
||||||
|
// de-gruyter-walter is a register person (provisional=false) AND the sender of W-0001.
|
||||||
|
// The orchestrator loads the register before documents, so the document loader's
|
||||||
|
// register-first match links the existing person and never mints a provisional one.
|
||||||
|
// A second run (documents reference the same person again) must not flip it true.
|
||||||
|
orchestrator.runImport();
|
||||||
|
orchestrator.runImport();
|
||||||
|
|
||||||
|
Person walter = personRepository.findBySourceRef("de-gruyter-walter").orElseThrow();
|
||||||
|
assertThat(walter.isProvisional()).isFalse();
|
||||||
|
Person eugenie = personRepository.findBySourceRef("de-gruyter-eugenie").orElseThrow();
|
||||||
|
assertThat(eugenie.isProvisional()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── synthetic-but-real artifact set ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void writeArtifacts(Path dir) throws Exception {
|
||||||
|
writeSheet(dir.resolve("canonical-tag-tree.xlsx"),
|
||||||
|
List.of("tag_path", "parent_name", "tag_name"),
|
||||||
|
List.of(
|
||||||
|
List.of("Themen", "", "Themen"),
|
||||||
|
List.of("Themen/Brautbriefe", "Themen", "Brautbriefe")));
|
||||||
|
|
||||||
|
writeSheet(dir.resolve("canonical-persons.xlsx"),
|
||||||
|
List.of("person_id", "last_name", "first_name", "maiden_name", "notes", "birth_date", "death_date", "provisional"),
|
||||||
|
List.of(
|
||||||
|
List.of("de-gruyter-walter", "de Gruyter", "Walter", "", "", "1865-01-01", "", "False"),
|
||||||
|
List.of("de-gruyter-eugenie", "de Gruyter", "Eugenie", "Wöhler", "", "", "", "False")));
|
||||||
|
|
||||||
|
Files.writeString(dir.resolve("canonical-persons-tree.json"), """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_1","firstName":"Walter","lastName":"de Gruyter","familyMember":true,"personId":"de-gruyter-walter"},
|
||||||
|
{"rowId":"row_2","firstName":"Eugenie","lastName":"de Gruyter","maidenName":"Wöhler","familyMember":true,"personId":"de-gruyter-eugenie"}
|
||||||
|
],"relationships":[
|
||||||
|
{"personId":"row_1","relatedPersonId":"row_2","type":"SPOUSE_OF","source":"verheiratet_mit"}
|
||||||
|
]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
writeSheet(dir.resolve("canonical-documents.xlsx"),
|
||||||
|
List.of("index", "file", "sender_person_id", "sender_name", "receiver_person_ids",
|
||||||
|
"receiver_names", "date_iso", "date_raw", "date_precision", "date_end", "location", "tags", "summary"),
|
||||||
|
List.of(
|
||||||
|
List.of("W-0001", "", "de-gruyter-walter", "Walter de Gruyter",
|
||||||
|
"de-gruyter-eugenie", "Eugenie de Gruyter", "1888-02-15", "15.2.1888", "DAY", "",
|
||||||
|
"Rotterdam", "Themen/Brautbriefe", "Geschäftsreise"),
|
||||||
|
List.of("W-0002", "", "de-gruyter-eugenie", "Eugenie de Gruyter",
|
||||||
|
"de-gruyter-walter", "Walter de Gruyter", "1888-02-16", "16.2.1888", "DAY", "",
|
||||||
|
"Middelburg", "Themen/Brautbriefe", "Reisepläne")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeSheet(Path file, List<String> headers, List<List<String>> rows) throws Exception {
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = wb.createSheet("Sheet1");
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
for (int i = 0; i < headers.size(); i++) {
|
||||||
|
header.createCell(i).setCellValue(headers.get(i));
|
||||||
|
}
|
||||||
|
for (int r = 0; r < rows.size(); r++) {
|
||||||
|
Row row = sheet.createRow(r + 1);
|
||||||
|
List<String> values = rows.get(r);
|
||||||
|
for (int c = 0; c < values.size(); c++) {
|
||||||
|
row.createCell(c).setCellValue(values.get(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (OutputStream out = Files.newOutputStream(file)) {
|
||||||
|
wb.write(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.InOrder;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
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.Mockito.inOrder;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class CanonicalImportOrchestratorTest {
|
||||||
|
|
||||||
|
@Mock TagTreeImporter tagTreeImporter;
|
||||||
|
@Mock PersonRegisterImporter personRegisterImporter;
|
||||||
|
@Mock PersonTreeImporter personTreeImporter;
|
||||||
|
@Mock DocumentImporter documentImporter;
|
||||||
|
|
||||||
|
private CanonicalImportOrchestrator orchestrator(Path dir) {
|
||||||
|
CanonicalImportOrchestrator o = new CanonicalImportOrchestrator(
|
||||||
|
tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter);
|
||||||
|
ReflectionTestUtils.setField(o, "canonicalDir", dir.toString());
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeAllArtifacts(Path dir) throws Exception {
|
||||||
|
Files.writeString(dir.resolve("canonical-tag-tree.xlsx"), "x");
|
||||||
|
Files.writeString(dir.resolve("canonical-persons.xlsx"), "x");
|
||||||
|
Files.writeString(dir.resolve("canonical-persons-tree.json"), "x");
|
||||||
|
Files.writeString(dir.resolve("canonical-documents.xlsx"), "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getStatus_isIdleByDefault(@TempDir Path dir) {
|
||||||
|
assertThat(orchestrator(dir).getStatus().state()).isEqualTo(ImportStatus.State.IDLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImport_loadsTagsAndPersonsBeforeDocuments(@TempDir Path dir) throws Exception {
|
||||||
|
writeAllArtifacts(dir);
|
||||||
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(0, List.of()));
|
||||||
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
|
o.runImport();
|
||||||
|
|
||||||
|
InOrder order = inOrder(tagTreeImporter, personRegisterImporter, personTreeImporter, documentImporter);
|
||||||
|
order.verify(tagTreeImporter).load(any());
|
||||||
|
order.verify(personRegisterImporter).load(any());
|
||||||
|
order.verify(personTreeImporter).load(any());
|
||||||
|
order.verify(documentImporter).load(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImport_setsStatusDone_onSuccess(@TempDir Path dir) throws Exception {
|
||||||
|
writeAllArtifacts(dir);
|
||||||
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(3, List.of()));
|
||||||
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
|
o.runImport();
|
||||||
|
|
||||||
|
assertThat(o.getStatus().state()).isEqualTo(ImportStatus.State.DONE);
|
||||||
|
assertThat(o.getStatus().processed()).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImport_failsClosed_whenAnArtifactIsMissing(@TempDir Path dir) throws Exception {
|
||||||
|
Files.writeString(dir.resolve("canonical-tag-tree.xlsx"), "x");
|
||||||
|
// the other three artifacts are absent
|
||||||
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
|
o.runImport();
|
||||||
|
|
||||||
|
assertThat(o.getStatus().state()).isEqualTo(ImportStatus.State.FAILED);
|
||||||
|
verify(tagTreeImporter, never()).load(any());
|
||||||
|
verify(documentImporter, never()).load(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImport_setsStatusFailed_whenLoaderThrows(@TempDir Path dir) throws Exception {
|
||||||
|
writeAllArtifacts(dir);
|
||||||
|
when(tagTreeImporter.load(any())).thenThrow(DomainException.badRequest(
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.IMPORT_ARTIFACT_INVALID, "bad"));
|
||||||
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
|
o.runImport();
|
||||||
|
|
||||||
|
assertThat(o.getStatus().state()).isEqualTo(ImportStatus.State.FAILED);
|
||||||
|
verify(documentImporter, never()).load(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_throwsConflict_whenAlreadyRunning(@TempDir Path dir) {
|
||||||
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
ReflectionTestUtils.setField(o, "currentStatus", new ImportStatus(
|
||||||
|
ImportStatus.State.RUNNING, "IMPORT_RUNNING", "running", 0, List.of(), null));
|
||||||
|
|
||||||
|
assertThatThrownBy(o::runImportAsync)
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("already in progress");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImport_aggregatesDocumentSkips(@TempDir Path dir) throws Exception {
|
||||||
|
writeAllArtifacts(dir);
|
||||||
|
when(documentImporter.load(any())).thenReturn(new DocumentImporter.LoadResult(1,
|
||||||
|
List.of(new ImportStatus.SkippedFile("fake.pdf", ImportStatus.SkipReason.INVALID_PDF_SIGNATURE))));
|
||||||
|
CanonicalImportOrchestrator o = orchestrator(dir);
|
||||||
|
|
||||||
|
o.runImport();
|
||||||
|
|
||||||
|
assertThat(o.getStatus().skipped()).isEqualTo(1);
|
||||||
|
assertThat(o.getStatus().skippedFiles())
|
||||||
|
.extracting(ImportStatus.SkippedFile::filename)
|
||||||
|
.containsExactly("fake.pdf");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class CanonicalSheetReaderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readRows_mapsCellsByHeaderName(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path xlsx = write(tempDir, List.of("index", "file"), List.of(List.of("W-0001", "scan.pdf")));
|
||||||
|
|
||||||
|
List<CanonicalSheetReader.Row> rows = CanonicalSheetReader.readRows(xlsx.toFile(), List.of("index", "file"));
|
||||||
|
|
||||||
|
assertThat(rows).hasSize(1);
|
||||||
|
assertThat(rows.get(0).get("index")).isEqualTo("W-0001");
|
||||||
|
assertThat(rows.get(0).get("file")).isEqualTo("scan.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readRows_throwsBadRequest_whenRequiredHeaderMissing(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path xlsx = write(tempDir, List.of("index"), List.of(List.of("W-0001")));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> CanonicalSheetReader.readRows(xlsx.toFile(), List.of("index", "file")))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("file");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_returnsEmptyString_forBlankCell(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path xlsx = write(tempDir, List.of("index", "file"), List.of(List.of("W-0001", "")));
|
||||||
|
|
||||||
|
List<CanonicalSheetReader.Row> rows = CanonicalSheetReader.readRows(xlsx.toFile(), List.of("index", "file"));
|
||||||
|
|
||||||
|
assertThat(rows.get(0).get("file")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_returnsEmptyString_forUnknownColumn(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path xlsx = write(tempDir, List.of("index"), List.of(List.of("W-0001")));
|
||||||
|
|
||||||
|
List<CanonicalSheetReader.Row> rows = CanonicalSheetReader.readRows(xlsx.toFile(), List.of("index"));
|
||||||
|
|
||||||
|
assertThat(rows.get(0).get("does_not_exist")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void get_returnsEmptyString_forTrailingColumns_whenRowShorterThanHeader(@TempDir Path tempDir) throws Exception {
|
||||||
|
// POI omits trailing empty cells, so a real-world artifact row can be narrower than
|
||||||
|
// the header. The missing columns must read as "" rather than throwing.
|
||||||
|
Path xlsx = write(tempDir,
|
||||||
|
List.of("index", "file", "summary"),
|
||||||
|
List.of(List.of("W-0001")));
|
||||||
|
|
||||||
|
List<CanonicalSheetReader.Row> rows = CanonicalSheetReader.readRows(xlsx.toFile(), List.of("index", "file", "summary"));
|
||||||
|
|
||||||
|
assertThat(rows.get(0).get("index")).isEqualTo("W-0001");
|
||||||
|
assertThat(rows.get(0).get("file")).isEmpty();
|
||||||
|
assertThat(rows.get(0).get("summary")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void splitList_splitsOnPipe() {
|
||||||
|
assertThat(CanonicalSheetReader.splitList("a|b|c")).containsExactly("a", "b", "c");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void splitList_returnsEmptyList_forBlank() {
|
||||||
|
assertThat(CanonicalSheetReader.splitList("")).isEmpty();
|
||||||
|
assertThat(CanonicalSheetReader.splitList(" ")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void splitList_returnsSingleElement_whenNoPipe() {
|
||||||
|
assertThat(CanonicalSheetReader.splitList("solo")).containsExactly("solo");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void splitList_trimsAndDropsEmptySegments() {
|
||||||
|
assertThat(CanonicalSheetReader.splitList("a| |b")).containsExactly("a", "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path write(Path dir, List<String> headers, List<List<String>> dataRows) throws Exception {
|
||||||
|
Path xlsx = dir.resolve("sheet.xlsx");
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = wb.createSheet("Sheet1");
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
for (int i = 0; i < headers.size(); i++) {
|
||||||
|
header.createCell(i).setCellValue(headers.get(i));
|
||||||
|
}
|
||||||
|
for (int r = 0; r < dataRows.size(); r++) {
|
||||||
|
Row row = sheet.createRow(r + 1);
|
||||||
|
List<String> values = dataRows.get(r);
|
||||||
|
for (int c = 0; c < values.size(); c++) {
|
||||||
|
row.createCell(c).setCellValue(values.get(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||||
|
wb.write(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return xlsx;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
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.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
|
import org.raddatz.familienarchiv.tag.TagService;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import software.amazon.awssdk.core.sync.RequestBody;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
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 DocumentImporterTest {
|
||||||
|
|
||||||
|
@Mock DocumentService documentService;
|
||||||
|
@Mock PersonService personService;
|
||||||
|
@Mock TagService tagService;
|
||||||
|
@Mock S3Client s3Client;
|
||||||
|
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||||
|
|
||||||
|
DocumentImporter importer;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
importer = new DocumentImporter(documentService, personService, tagService, s3Client, thumbnailAsyncRunner);
|
||||||
|
ReflectionTestUtils.setField(importer, "bucketName", "test-bucket");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── security regression — ported from MassImportServiceTest — do not remove ─────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsFalse_whenNull() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", (String) null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsFalse_whenBlank() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", " ")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsFalse_whenForwardSlash() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "etc/passwd")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsFalse_whenBackslash() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "..\\etc\\passwd")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsFalse_whenDotDot() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "doc..evil.pdf")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsFalse_whenIsDotDot() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "..")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsFalse_whenAbsolutePath() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "/etc/passwd")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsFalse_whenNullByte() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "file\0.pdf")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsFalse_whenUnicodeDivisionSlash() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "foo∕bar.pdf")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsFalse_whenFullwidthSlash() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "foo/bar.pdf")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsFalse_whenReverseSolidusOperator() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "foo⧵bar.pdf")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsTrue_whenPlainBasename() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "document.pdf")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsTrue_whenLeadingDot() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", ".hidden.pdf")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isValidImportFilename_returnsTrue_whenHasSpaces() {
|
||||||
|
assertThat((Boolean) ReflectionTestUtils.invokeMethod(importer, "isValidImportFilename", "Brief an Oma.pdf")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFileRecursive_throwsDomainException_whenSymlinkEscapesImportDir(
|
||||||
|
@TempDir Path importDirPath, @TempDir Path outsideDir) throws Exception {
|
||||||
|
Path outsideFile = outsideDir.resolve("secret.pdf");
|
||||||
|
Files.writeString(outsideFile, "sensitive");
|
||||||
|
Files.createSymbolicLink(importDirPath.resolve("secret.pdf"), outsideFile);
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", importDirPath.toString());
|
||||||
|
|
||||||
|
org.assertj.core.api.Assertions.assertThatThrownBy(
|
||||||
|
() -> ReflectionTestUtils.invokeMethod(importer, "findFileRecursive", "secret.pdf"))
|
||||||
|
.isInstanceOf(org.raddatz.familienarchiv.exception.DomainException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── path traversal in the file column cannot escape importDir ───────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_rejectsFileColumn_whenBasenameIsTraversalToken(@TempDir Path tempDir) throws Exception {
|
||||||
|
// A file column whose basename is itself a traversal token must be rejected
|
||||||
|
// outright, never used for disk I/O.
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0001", "evil/..", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
|
DocumentImporter.LoadResult result = importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
assertThat(result.skippedFiles())
|
||||||
|
.extracting(ImportStatus.SkippedFile::reason)
|
||||||
|
.containsExactly(ImportStatus.SkipReason.INVALID_FILENAME_PATH_TRAVERSAL);
|
||||||
|
verify(documentService, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_traversalFileColumn_cannotEscapeImportDir_yieldsPlaceholder(@TempDir Path tempDir) throws Exception {
|
||||||
|
// ../../etc/cron.d/x reduces to basename "x"; the disk lookup is confined to
|
||||||
|
// importDir, so no file is found, nothing is uploaded, and the row becomes a
|
||||||
|
// metadata-only PLACEHOLDER — the file outside importDir is never read.
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0001", "../../etc/cron.d/x", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getStatus() == DocumentStatus.PLACEHOLDER));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PDF magic-byte guard — ported — do not remove ──────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_skipsFile_whenNotPdfMagicBytes(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Files.writeString(tempDir.resolve("W-0001.pdf"), "not a pdf");
|
||||||
|
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0001", "..\\__scan\\W-0001.pdf", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
|
DocumentImporter.LoadResult result = importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
assertThat(result.skippedFiles())
|
||||||
|
.extracting(ImportStatus.SkippedFile::reason)
|
||||||
|
.containsExactly(ImportStatus.SkipReason.INVALID_PDF_SIGNATURE);
|
||||||
|
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_skipsFile_whenMagicByteCheckThrowsIoException(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Files.writeString(tempDir.resolve("W-0001.pdf"), "content");
|
||||||
|
lenient().when(documentService.findByOriginalFilename(any())).thenReturn(Optional.empty());
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0001", "..\\__scan\\W-0001.pdf", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
|
DocumentImporter spyImporter = org.mockito.Mockito.spy(importer);
|
||||||
|
org.mockito.Mockito.doThrow(new java.io.IOException("read error"))
|
||||||
|
.when(spyImporter).openFileStream(any(File.class));
|
||||||
|
|
||||||
|
DocumentImporter.LoadResult result = spyImporter.load(xlsx.toFile());
|
||||||
|
|
||||||
|
assertThat(result.skippedFiles())
|
||||||
|
.extracting(ImportStatus.SkippedFile::reason)
|
||||||
|
.containsExactly(ImportStatus.SkipReason.FILE_READ_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_skipsAlreadyExists_whenDocumentUploadedNotPlaceholder(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Document existing = Document.builder().id(UUID.randomUUID())
|
||||||
|
.originalFilename("W-0001").status(DocumentStatus.UPLOADED).build();
|
||||||
|
when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.of(existing));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
|
DocumentImporter.LoadResult result = importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
assertThat(result.skippedFiles())
|
||||||
|
.extracting(ImportStatus.SkippedFile::reason)
|
||||||
|
.containsExactly(ImportStatus.SkipReason.ALREADY_EXISTS);
|
||||||
|
verify(documentService, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── file column drives status: present → UPLOADED, empty → PLACEHOLDER ───────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_uploadsToS3_andSetsStatusUploaded_whenFilePresent(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
byte[] pdf = {0x25, 0x50, 0x44, 0x46, 0x2D};
|
||||||
|
Files.write(tempDir.resolve("W-0001.pdf"), pdf);
|
||||||
|
when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0001", "..\\__scan\\W-0001.pdf", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getStatus() == DocumentStatus.UPLOADED));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_setsStatusPlaceholder_whenFileColumnEmpty(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename("W-0099")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0099", "", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getStatus() == DocumentStatus.PLACEHOLDER));
|
||||||
|
verify(s3Client, never()).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── attribution routing — register-first + always retain raw ────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_linksRegisterSender_andRetainsRawSenderText(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Person walter = Person.builder().id(UUID.randomUUID()).sourceRef("de-gruyter-walter")
|
||||||
|
.firstName("Walter").lastName("de Gruyter").build();
|
||||||
|
when(documentService.findByOriginalFilename("W-0001")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.findBySourceRef("de-gruyter-walter")).thenReturn(Optional.of(walter));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0001", "", "de-gruyter-walter", "Walter de Gruyter",
|
||||||
|
"", "", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||||
|
d.getSender() == walter && "Walter de Gruyter".equals(d.getSenderText())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_createsProvisionalSender_whenSlugUnmatchedInRegister(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Person provisional = Person.builder().id(UUID.randomUUID()).sourceRef("schwester-hanni")
|
||||||
|
.lastName("Schwester Hanni").provisional(true).build();
|
||||||
|
when(documentService.findByOriginalFilename("W-0002")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.findBySourceRef("schwester-hanni")).thenReturn(Optional.empty());
|
||||||
|
when(personService.upsertBySourceRef(any())).thenReturn(provisional);
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0002", "", "schwester-hanni", "Schwester Hanni",
|
||||||
|
"", "", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<PersonUpsertCommand> captor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService).upsertBySourceRef(captor.capture());
|
||||||
|
assertThat(captor.getValue().provisional()).isTrue();
|
||||||
|
assertThat(captor.getValue().lastName()).isEqualTo("Schwester Hanni");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_createsNoSenderPerson_whenSlugEmptyButRawPresent(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename("W-0003")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0003", "", "", "?",
|
||||||
|
"", "", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(personService, never()).findBySourceRef(any());
|
||||||
|
verify(personService, never()).upsertBySourceRef(any());
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||||
|
d.getSender() == null && "?".equals(d.getSenderText())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_splitsMultipleReceivers_andRetainsRawReceiverText(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Person herbert = Person.builder().id(UUID.randomUUID()).sourceRef("cram-herbert").lastName("Cram").build();
|
||||||
|
Person clara = Person.builder().id(UUID.randomUUID()).sourceRef("clara").lastName("Clara").build();
|
||||||
|
when(documentService.findByOriginalFilename("W-0004")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.findBySourceRef("cram-herbert")).thenReturn(Optional.of(herbert));
|
||||||
|
when(personService.findBySourceRef("clara")).thenReturn(Optional.of(clara));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0004", "", "", "",
|
||||||
|
"cram-herbert|clara", "Herbert Cram|Clara", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||||
|
d.getReceivers().size() == 2
|
||||||
|
&& d.getReceivers().contains(herbert)
|
||||||
|
&& d.getReceivers().contains(clara)
|
||||||
|
&& "Herbert Cram|Clara".equals(d.getReceiverText())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── clean date values parse without semantic logic ──────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_parsesCleanDateAndPrecision(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename("W-0005")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0005", "", "", "",
|
||||||
|
"", "", "1916-06-01", "1.6.1916", "MONTH", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||||
|
LocalDate.of(1916, 6, 1).equals(d.getDocumentDate())
|
||||||
|
&& d.getMetaDatePrecision() == org.raddatz.familienarchiv.document.DatePrecision.MONTH
|
||||||
|
&& "1.6.1916".equals(d.getMetaDateRaw())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_attachesTagBySourceRef(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Brautbriefe").sourceRef("Themen/Brautbriefe").build();
|
||||||
|
when(documentService.findByOriginalFilename("W-0006")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(tagService.findBySourceRef("Themen/Brautbriefe")).thenReturn(Optional.of(tag));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRowWithTag("W-0006", "Themen/Brautbriefe"));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getTags().contains(tag)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── idempotency — update existing document in place by index ─────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_updatesExistingDocumentInPlace_whenIndexExists(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Document existing = Document.builder().id(UUID.randomUUID())
|
||||||
|
.originalFilename("W-0007").status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentService.findByOriginalFilename("W-0007")).thenReturn(Optional.of(existing));
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0007", "", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d -> d.getId().equals(existing.getId())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── canonical collections are authoritative — re-import prunes removed links ──────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_prunesReceiversAndTags_whenCanonicalRowShrinks(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
Person staleReceiver = Person.builder().id(UUID.randomUUID()).sourceRef("stale-receiver").lastName("Stale").build();
|
||||||
|
Tag staleTag = Tag.builder().id(UUID.randomUUID()).name("Stale").sourceRef("Themen/Stale").build();
|
||||||
|
Document existing = Document.builder().id(UUID.randomUUID())
|
||||||
|
.originalFilename("W-0008").status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
existing.getReceivers().add(staleReceiver);
|
||||||
|
existing.getTags().add(staleTag);
|
||||||
|
when(documentService.findByOriginalFilename("W-0008")).thenReturn(Optional.of(existing));
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
// The canonical row now carries no receiver and no tag: both stale links must go.
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0008", "", "", "", "", "", "", "", "", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||||
|
d.getReceivers().isEmpty() && d.getTags().isEmpty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── title carries the honest date label — never a precision the data lacks ───────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_buildsTitleWithMonthLabel_whenPrecisionIsMonth(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename("W-0100")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0100", "", "", "", "", "",
|
||||||
|
"1916-06-01", "Juni 1916", "MONTH", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||||
|
d.getTitle().contains("Juni 1916") && !d.getTitle().contains("1. Juni")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_buildsTitleWithFullDate_whenPrecisionIsDay(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename("W-0101")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0101", "", "", "", "", "",
|
||||||
|
"1943-12-24", "24.12.1943", "DAY", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||||
|
d.getTitle().contains("24. Dezember 1943")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_buildsTitleFromIndexOnly_whenDateUnknown(@TempDir Path tempDir) throws Exception {
|
||||||
|
ReflectionTestUtils.setField(importer, "importDir", tempDir.toString());
|
||||||
|
when(documentService.findByOriginalFilename("W-0102")).thenReturn(Optional.empty());
|
||||||
|
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
Path xlsx = writeDocs(tempDir, docRow("W-0102", "", "", "", "", "",
|
||||||
|
"", "?", "UNKNOWN", ""));
|
||||||
|
|
||||||
|
importer.load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(documentService).save(org.mockito.ArgumentMatchers.argThat(d ->
|
||||||
|
d.getTitle().equals("W-0102")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Map<String, String> docRow(String index, String file, String senderId, String senderName,
|
||||||
|
String receiverIds, String receiverNames, String dateIso,
|
||||||
|
String dateRaw, String datePrecision, String dateEnd) {
|
||||||
|
Map<String, String> r = new LinkedHashMap<>();
|
||||||
|
r.put("index", index);
|
||||||
|
r.put("file", file);
|
||||||
|
r.put("sender_person_id", senderId);
|
||||||
|
r.put("sender_name", senderName);
|
||||||
|
r.put("receiver_person_ids", receiverIds);
|
||||||
|
r.put("receiver_names", receiverNames);
|
||||||
|
r.put("date_iso", dateIso);
|
||||||
|
r.put("date_raw", dateRaw);
|
||||||
|
r.put("date_precision", datePrecision);
|
||||||
|
r.put("date_end", dateEnd);
|
||||||
|
r.put("location", "");
|
||||||
|
r.put("tags", "");
|
||||||
|
r.put("summary", "");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> docRowWithTag(String index, String tagPath) {
|
||||||
|
Map<String, String> r = docRow(index, "", "", "", "", "", "", "", "", "");
|
||||||
|
r.put("tags", tagPath);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SafeVarargs
|
||||||
|
private Path writeDocs(Path dir, Map<String, String>... rows) throws Exception {
|
||||||
|
Path xlsx = dir.resolve("canonical-documents.xlsx");
|
||||||
|
List<String> headers = List.of("index", "file", "sender_person_id", "sender_name",
|
||||||
|
"receiver_person_ids", "receiver_names", "date_iso", "date_raw", "date_precision",
|
||||||
|
"date_end", "location", "tags", "summary");
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = wb.createSheet("Sheet1");
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
for (int i = 0; i < headers.size(); i++) {
|
||||||
|
header.createCell(i).setCellValue(headers.get(i));
|
||||||
|
}
|
||||||
|
for (int r = 0; r < rows.length; r++) {
|
||||||
|
Row row = sheet.createRow(r + 1);
|
||||||
|
for (int c = 0; c < headers.size(); c++) {
|
||||||
|
row.createCell(c).setCellValue(rows[r].getOrDefault(headers.get(c), ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||||
|
wb.write(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return xlsx;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.DynamicTest;
|
||||||
|
import org.junit.jupiter.api.TestFactory;
|
||||||
|
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asserts the Java title label against the SAME shared fixture table the TS
|
||||||
|
* formatter spec uses ({@code docs/date-label-fixtures.json}). This is the
|
||||||
|
* drift guard requested in #666 review: the two label implementations cannot
|
||||||
|
* silently diverge (en-dash vs hyphen, "ca." vs "circa", season words, range
|
||||||
|
* collapse) because both are pinned to one committed rule set.
|
||||||
|
*/
|
||||||
|
class DocumentTitleFormatterTest {
|
||||||
|
|
||||||
|
@TestFactory
|
||||||
|
List<DynamicTest> matchesSharedFixtureTable() throws Exception {
|
||||||
|
// Maven runs tests from the backend/ module dir; the fixture lives at repo-root docs/.
|
||||||
|
Path fixture = Path.of("..", "docs", "date-label-fixtures.json");
|
||||||
|
JsonNode root = new ObjectMapper().readTree(Files.readString(fixture));
|
||||||
|
List<DynamicTest> tests = new ArrayList<>();
|
||||||
|
for (JsonNode c : root.get("cases")) {
|
||||||
|
String name = c.get("name").asText();
|
||||||
|
LocalDate anchor = parseDate(c.get("anchor"));
|
||||||
|
DatePrecision precision = DatePrecision.valueOf(c.get("precision").asText());
|
||||||
|
LocalDate end = parseDate(c.get("end"));
|
||||||
|
String raw = c.get("raw").isNull() ? null : c.get("raw").asText();
|
||||||
|
String expected = c.get("expected").asText();
|
||||||
|
tests.add(DynamicTest.dynamicTest(name, () ->
|
||||||
|
assertThat(DocumentTitleFormatter.formatTitleDate(anchor, precision, end, raw))
|
||||||
|
.isEqualTo(expected)));
|
||||||
|
}
|
||||||
|
return tests;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate parseDate(JsonNode node) {
|
||||||
|
return node == null || node.isNull() ? null : LocalDate.parse(node.asText());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
package org.raddatz.familienarchiv.importing;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
|
||||||
import org.raddatz.familienarchiv.document.Document;
|
|
||||||
import org.raddatz.familienarchiv.document.DocumentService;
|
|
||||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
|
||||||
import org.raddatz.familienarchiv.document.ThumbnailAsyncRunner;
|
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
|
||||||
import org.raddatz.familienarchiv.tag.Tag;
|
|
||||||
import org.raddatz.familienarchiv.tag.TagService;
|
|
||||||
import org.raddatz.familienarchiv.person.PersonService;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
import software.amazon.awssdk.core.sync.RequestBody;
|
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
|
||||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
|
||||||
|
|
||||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
|
||||||
import org.xml.sax.SAXParseException;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.zip.ZipEntry;
|
|
||||||
import java.util.zip.ZipOutputStream;
|
|
||||||
|
|
||||||
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.Mockito.*;
|
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class MassImportServiceTest {
|
|
||||||
|
|
||||||
@Mock DocumentService documentService;
|
|
||||||
@Mock PersonService personService;
|
|
||||||
@Mock TagService tagService;
|
|
||||||
@Mock S3Client s3Client;
|
|
||||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
|
||||||
|
|
||||||
MassImportService service;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
service = new MassImportService(documentService, personService, tagService, s3Client, thumbnailAsyncRunner);
|
|
||||||
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
|
|
||||||
ReflectionTestUtils.setField(service, "importDir", "/import");
|
|
||||||
ReflectionTestUtils.setField(service, "colIndex", 0);
|
|
||||||
ReflectionTestUtils.setField(service, "colBox", 1);
|
|
||||||
ReflectionTestUtils.setField(service, "colFolder", 2);
|
|
||||||
ReflectionTestUtils.setField(service, "colSender", 3);
|
|
||||||
ReflectionTestUtils.setField(service, "colReceivers", 5);
|
|
||||||
ReflectionTestUtils.setField(service, "colDate", 7);
|
|
||||||
ReflectionTestUtils.setField(service, "colLocation", 9);
|
|
||||||
ReflectionTestUtils.setField(service, "colTags", 10);
|
|
||||||
ReflectionTestUtils.setField(service, "colSummary", 11);
|
|
||||||
ReflectionTestUtils.setField(service, "colTranscription", 13);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── getStatus ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getStatus_returnsIdleByDefault() {
|
|
||||||
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getStatus_hasStatusCode_IMPORT_IDLE_byDefault() {
|
|
||||||
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_IDLE");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── runImportAsync ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
|
|
||||||
// /import directory doesn't exist in test environment → IOException → IMPORT_FAILED_INTERNAL
|
|
||||||
service.runImportAsync();
|
|
||||||
|
|
||||||
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
|
|
||||||
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_INTERNAL");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void runImportAsync_readsFromConfiguredImportDir(@TempDir Path tempDir) {
|
|
||||||
// Empty temp dir → findSpreadsheetFile throws "no spreadsheet" with the
|
|
||||||
// configured path in the message. Proves the field, not a constant,
|
|
||||||
// drives the lookup.
|
|
||||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
|
||||||
|
|
||||||
service.runImportAsync();
|
|
||||||
|
|
||||||
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
|
|
||||||
assertThat(service.getStatus().message()).contains(tempDir.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void runImportAsync_setsStatusCode_IMPORT_FAILED_NO_SPREADSHEET_whenDirIsEmpty(@TempDir Path tempDir) {
|
|
||||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
|
||||||
|
|
||||||
service.runImportAsync();
|
|
||||||
|
|
||||||
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_FAILED_NO_SPREADSHEET");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void runImportAsync_setsStatusCode_IMPORT_DONE_whenSpreadsheetHasNoDataRows(@TempDir Path tempDir) throws Exception {
|
|
||||||
Path xlsx = tempDir.resolve("import.xlsx");
|
|
||||||
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
|
||||||
wb.createSheet("Sheet1");
|
|
||||||
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
|
||||||
wb.write(out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ReflectionTestUtils.setField(service, "importDir", tempDir.toString());
|
|
||||||
|
|
||||||
service.runImportAsync();
|
|
||||||
|
|
||||||
assertThat(service.getStatus().statusCode()).isEqualTo("IMPORT_DONE");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
|
||||||
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
|
||||||
MassImportService.State.RUNNING, "IMPORT_RUNNING", "Running...", 0, LocalDateTime.now());
|
|
||||||
ReflectionTestUtils.setField(service, "currentStatus", running);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.runImportAsync())
|
|
||||||
.isInstanceOf(DomainException.class)
|
|
||||||
.hasMessageContaining("already in progress");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── importSingleDocument — skip already uploaded ─────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_skips_whenDocumentAlreadyUploadedNotPlaceholder() {
|
|
||||||
Document existing = Document.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.originalFilename("doc001.pdf")
|
|
||||||
.status(DocumentStatus.UPLOADED)
|
|
||||||
.build();
|
|
||||||
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
|
|
||||||
|
|
||||||
service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
|
||||||
|
|
||||||
verify(documentService, never()).save(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── importSingleDocument — create new document (metadata only) ───────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_createsNewDocument_whenNotExists() {
|
|
||||||
when(documentService.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
service.importSingleDocument(minimalCells("doc002.pdf"), Optional.empty(), "doc002.pdf", "doc002");
|
|
||||||
|
|
||||||
verify(documentService).save(argThat(d ->
|
|
||||||
d.getOriginalFilename().equals("doc002.pdf")
|
|
||||||
&& d.getStatus() == DocumentStatus.PLACEHOLDER));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── importSingleDocument — update existing placeholder ──────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_updatesExistingPlaceholder() {
|
|
||||||
Document placeholder = Document.builder()
|
|
||||||
.id(UUID.randomUUID())
|
|
||||||
.originalFilename("existing.pdf")
|
|
||||||
.status(DocumentStatus.PLACEHOLDER)
|
|
||||||
.build();
|
|
||||||
when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(placeholder));
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
service.importSingleDocument(minimalCells("existing.pdf"), Optional.empty(), "existing.pdf", "existing");
|
|
||||||
|
|
||||||
verify(documentService).save(same(placeholder));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── importSingleDocument — with file (S3 upload) ─────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_uploadsFileToS3_andSetsStatusUploaded(@TempDir Path tempDir) throws Exception {
|
|
||||||
Path tempFile = tempDir.resolve("doc003.pdf");
|
|
||||||
Files.write(tempFile, "PDF content".getBytes());
|
|
||||||
|
|
||||||
when(documentService.findByOriginalFilename("doc003.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
service.importSingleDocument(
|
|
||||||
minimalCells("doc003.pdf"), Optional.of(tempFile.toFile()), "doc003.pdf", "doc003");
|
|
||||||
|
|
||||||
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
|
||||||
verify(documentService).save(argThat(d -> d.getStatus() == DocumentStatus.UPLOADED));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
|
|
||||||
Path tempFile = tempDir.resolve("fail.pdf");
|
|
||||||
Files.write(tempFile, "data".getBytes());
|
|
||||||
|
|
||||||
when(documentService.findByOriginalFilename("fail.pdf")).thenReturn(Optional.empty());
|
|
||||||
doThrow(new RuntimeException("S3 error"))
|
|
||||||
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
|
||||||
|
|
||||||
service.importSingleDocument(
|
|
||||||
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
|
|
||||||
|
|
||||||
verify(documentService, never()).save(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── importSingleDocument — sender handling ───────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_setsNullSender_whenSenderCellIsBlank() {
|
|
||||||
when(documentService.findByOriginalFilename("nosender.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
List<String> cells = buildCells("nosender.pdf", "", "", "");
|
|
||||||
service.importSingleDocument(cells, Optional.empty(), "nosender.pdf", "nosender");
|
|
||||||
|
|
||||||
verify(documentService).save(argThat(d -> d.getSender() == null));
|
|
||||||
verify(personService, never()).findOrCreateByAlias(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_createsSender_whenSenderCellIsNonBlank() {
|
|
||||||
Person sender = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
|
|
||||||
when(documentService.findByOriginalFilename("withsender.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(sender);
|
|
||||||
|
|
||||||
List<String> cells = buildCells("withsender.pdf", "Walter Müller", "", "");
|
|
||||||
service.importSingleDocument(cells, Optional.empty(), "withsender.pdf", "withsender");
|
|
||||||
|
|
||||||
verify(personService).findOrCreateByAlias("Walter Müller");
|
|
||||||
verify(documentService).save(argThat(d -> d.getSender() == sender));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── importSingleDocument — tag handling ─────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_createsTag_whenTagCellIsNonBlank() {
|
|
||||||
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
|
||||||
when(documentService.findByOriginalFilename("tagged.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
|
||||||
|
|
||||||
List<String> cells = buildCells("tagged.pdf", "", "", "Familie");
|
|
||||||
service.importSingleDocument(cells, Optional.empty(), "tagged.pdf", "tagged");
|
|
||||||
|
|
||||||
verify(tagService).findOrCreate("Familie");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_doesNotCreateTag_whenTagCellIsBlank() {
|
|
||||||
when(documentService.findByOriginalFilename("notag.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
List<String> cells = buildCells("notag.pdf", "", "", "");
|
|
||||||
service.importSingleDocument(cells, Optional.empty(), "notag.pdf", "notag");
|
|
||||||
|
|
||||||
verify(tagService, never()).findOrCreate(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── importSingleDocument — metadataComplete heuristic ───────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_metadataComplete_whenSenderPresent() {
|
|
||||||
Person sender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
|
|
||||||
when(documentService.findByOriginalFilename("meta.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
when(personService.findOrCreateByAlias("A B")).thenReturn(sender);
|
|
||||||
|
|
||||||
List<String> cells = buildCells("meta.pdf", "A B", "", "");
|
|
||||||
service.importSingleDocument(cells, Optional.empty(), "meta.pdf", "meta");
|
|
||||||
|
|
||||||
verify(documentService).save(argThat(Document::isMetadataComplete));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_metadataIncomplete_whenNoKeyFieldsPresent() {
|
|
||||||
when(documentService.findByOriginalFilename("nometa.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
List<String> cells = buildCells("nometa.pdf", "", "", "");
|
|
||||||
service.importSingleDocument(cells, Optional.empty(), "nometa.pdf", "nometa");
|
|
||||||
|
|
||||||
verify(documentService).save(argThat(d -> !d.isMetadataComplete()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── importSingleDocument — blank fields set to null ─────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_setsBlankFieldsToNull() {
|
|
||||||
when(documentService.findByOriginalFilename("blank.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
List<String> cells = buildCells("blank.pdf", "", "", "");
|
|
||||||
service.importSingleDocument(cells, Optional.empty(), "blank.pdf", "blank");
|
|
||||||
|
|
||||||
verify(documentService).save(argThat(d ->
|
|
||||||
d.getLocation() == null &&
|
|
||||||
d.getSummary() == null &&
|
|
||||||
d.getTranscription() == null &&
|
|
||||||
d.getArchiveBox() == null &&
|
|
||||||
d.getArchiveFolder() == null));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── processRows — via ReflectionTestUtils ────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void processRows_returnsZero_whenOnlyHeaderRow() {
|
|
||||||
List<List<String>> rows = List.of(List.of("header", "col1"));
|
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
|
||||||
assertThat(result).isEqualTo(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void processRows_skipsRowWithBlankIndex() {
|
|
||||||
List<List<String>> rows = List.of(
|
|
||||||
List.of("header"),
|
|
||||||
minimalCells("") // blank index
|
|
||||||
);
|
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
|
||||||
assertThat(result).isEqualTo(0);
|
|
||||||
verify(documentService, never()).findByOriginalFilename(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void processRows_addsExtension_whenIndexHasNoDot() {
|
|
||||||
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
List<List<String>> rows = List.of(
|
|
||||||
List.of("header"),
|
|
||||||
minimalCells("doc001") // no dot → appends ".pdf"
|
|
||||||
);
|
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
|
||||||
|
|
||||||
assertThat(result).isEqualTo(1);
|
|
||||||
verify(documentService).findByOriginalFilename("doc001.pdf");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void processRows_usesFilenameAsIs_whenIndexHasDot() {
|
|
||||||
when(documentService.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
List<List<String>> rows = List.of(
|
|
||||||
List.of("header"),
|
|
||||||
minimalCells("doc002.pdf") // has dot → used as-is
|
|
||||||
);
|
|
||||||
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
|
||||||
|
|
||||||
assertThat(result).isEqualTo(1);
|
|
||||||
verify(documentService).findByOriginalFilename("doc002.pdf");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── importSingleDocument — non-blank optional fields ────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_setsNonNullOptionalFields_whenPresent() {
|
|
||||||
when(documentService.findByOriginalFilename("rich.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
// box=1, folder=2, location=9, summary=11, transcription=13
|
|
||||||
List<String> cells = List.of(
|
|
||||||
"rich.pdf", // 0: index
|
|
||||||
"Box A", // 1: box
|
|
||||||
"Folder B", // 2: folder
|
|
||||||
"", // 3: sender
|
|
||||||
"", // 4: unused
|
|
||||||
"", // 5: receivers
|
|
||||||
"", // 6: unused
|
|
||||||
"", // 7: date
|
|
||||||
"", // 8: unused
|
|
||||||
"Hamburg", // 9: location
|
|
||||||
"", // 10: tags
|
|
||||||
"A summary", // 11: summary
|
|
||||||
"", // 12: unused
|
|
||||||
"A transcript" // 13: transcription
|
|
||||||
);
|
|
||||||
|
|
||||||
service.importSingleDocument(cells, Optional.empty(), "rich.pdf", "rich");
|
|
||||||
|
|
||||||
verify(documentService).save(argThat(d ->
|
|
||||||
"Box A".equals(d.getArchiveBox()) &&
|
|
||||||
"Folder B".equals(d.getArchiveFolder()) &&
|
|
||||||
"Hamburg".equals(d.getLocation()) &&
|
|
||||||
"A summary".equals(d.getSummary()) &&
|
|
||||||
"A transcript".equals(d.getTranscription())));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_setsMetadataComplete_whenReceiversArePresent() {
|
|
||||||
Person receiver = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
|
|
||||||
when(documentService.findByOriginalFilename("rcv.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(receiver);
|
|
||||||
|
|
||||||
List<String> cells = List.of(
|
|
||||||
"rcv.pdf", "", "", "", "", "Walter Müller", "", "", "", "", "", "", "", "");
|
|
||||||
service.importSingleDocument(cells, Optional.empty(), "rcv.pdf", "rcv");
|
|
||||||
|
|
||||||
verify(documentService).save(argThat(Document::isMetadataComplete));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void importSingleDocument_setsMetadataComplete_whenDateIsPresent() {
|
|
||||||
when(documentService.findByOriginalFilename("dated.pdf")).thenReturn(Optional.empty());
|
|
||||||
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
List<String> cells = List.of(
|
|
||||||
"dated.pdf", "", "", "", "", "", "", "2024-03-15", "", "", "", "", "", "");
|
|
||||||
service.importSingleDocument(cells, Optional.empty(), "dated.pdf", "dated");
|
|
||||||
|
|
||||||
verify(documentService).save(argThat(Document::isMetadataComplete));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── buildTitle — null location ───────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildTitle_withNullLocation_skipsLocationPart() {
|
|
||||||
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
|
|
||||||
"doc005", LocalDate.of(1940, 5, 1), (String) null);
|
|
||||||
assertThat(result).contains("doc005").contains("1940");
|
|
||||||
assertThat(result).doesNotContain("Berlin");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── parseDate — via ReflectionTestUtils ─────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void parseDate_returnsNull_whenValueIsNull() {
|
|
||||||
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", (String) null);
|
|
||||||
assertThat(result).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void parseDate_returnsNull_whenValueIsBlank() {
|
|
||||||
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", " ");
|
|
||||||
assertThat(result).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void parseDate_returnsDate_whenValidIsoFormat() {
|
|
||||||
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", "2024-03-15");
|
|
||||||
assertThat(result).isEqualTo(LocalDate.of(2024, 3, 15));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void parseDate_returnsNull_whenInvalidDateString() {
|
|
||||||
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", "15.03.2024");
|
|
||||||
assertThat(result).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── buildTitle — via ReflectionTestUtils ────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildTitle_withDateAndLocation() {
|
|
||||||
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
|
|
||||||
"doc001", LocalDate.of(1940, 5, 1), "Berlin");
|
|
||||||
assertThat(result).contains("doc001").contains("Berlin").contains("1940");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildTitle_withDateOnly() {
|
|
||||||
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
|
|
||||||
"doc002", LocalDate.of(1960, 8, 15), "");
|
|
||||||
assertThat(result).contains("doc002").contains("1960");
|
|
||||||
assertThat(result).doesNotContain("Berlin");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildTitle_withIndexOnly_whenDateAndLocationAreNull() {
|
|
||||||
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
|
|
||||||
"doc003", null, "");
|
|
||||||
assertThat(result).isEqualTo("doc003");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void buildTitle_withLocationOnly_whenDateIsNull() {
|
|
||||||
// date=null, location present → date part skipped, location appended
|
|
||||||
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
|
|
||||||
"doc004", null, "Berlin");
|
|
||||||
assertThat(result).contains("doc004").contains("Berlin");
|
|
||||||
assertThat(result).doesNotContain("("); // no date part
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── getCell — via ReflectionTestUtils ───────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getCell_returnsEmptyString_whenColBeyondListSize() {
|
|
||||||
List<String> cells = List.of("a", "b");
|
|
||||||
String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 5);
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getCell_returnsEmptyString_whenValueIsNull() {
|
|
||||||
List<String> cells = new ArrayList<>();
|
|
||||||
cells.add(null);
|
|
||||||
cells.add("b");
|
|
||||||
String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 0);
|
|
||||||
assertThat(result).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getCell_returnsTrimmedValue() {
|
|
||||||
List<String> cells = List.of(" hello ", "world");
|
|
||||||
String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 0);
|
|
||||||
assertThat(result).isEqualTo("hello");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── readOds — XXE security regression ───────────────────────────────────
|
|
||||||
|
|
||||||
// Security regression — do not remove.
|
|
||||||
@Test
|
|
||||||
void readOds_rejects_xxe_doctype_payload(@TempDir Path tempDir) throws Exception {
|
|
||||||
File malicious = buildXxeOds(tempDir, "file:///etc/hostname");
|
|
||||||
assertThatThrownBy(() -> service.readOds(malicious))
|
|
||||||
.isInstanceOf(SAXParseException.class)
|
|
||||||
.hasMessageContaining("DOCTYPE is disallowed");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void readOds_parses_valid_ods_correctly(@TempDir Path tempDir) throws Exception {
|
|
||||||
File valid = buildValidOds(tempDir, "Mustermann");
|
|
||||||
List<List<String>> rows = service.readOds(valid);
|
|
||||||
assertThat(rows).isNotEmpty();
|
|
||||||
assertThat(rows.get(0)).contains("Mustermann");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a minimal 14-element cell row with the given filename at index 0
|
|
||||||
* and blanks for all optional fields.
|
|
||||||
*/
|
|
||||||
private List<String> minimalCells(String filename) {
|
|
||||||
return buildCells(filename, "", "", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a cell row with sender, receiver, and tag controls.
|
|
||||||
* Layout matches the default column indices set in setUp().
|
|
||||||
*/
|
|
||||||
private List<String> buildCells(String filename, String sender, String receivers, String tag) {
|
|
||||||
// 14 elements: index=0,box=1,folder=2,sender=3,[4],receivers=5,[6],date=7,[8],location=9,tag=10,summary=11,[12],transcription=13
|
|
||||||
return List.of(
|
|
||||||
filename, // 0: index
|
|
||||||
"", // 1: box
|
|
||||||
"", // 2: folder
|
|
||||||
sender, // 3: sender
|
|
||||||
"", // 4: (unused)
|
|
||||||
receivers, // 5: receivers
|
|
||||||
"", // 6: (unused)
|
|
||||||
"", // 7: date
|
|
||||||
"", // 8: (unused)
|
|
||||||
"", // 9: location
|
|
||||||
tag, // 10: tags
|
|
||||||
"", // 11: summary
|
|
||||||
"", // 12: (unused)
|
|
||||||
"" // 13: transcription
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates a minimal ODS ZIP containing a content.xml with an XXE payload. */
|
|
||||||
private File buildXxeOds(Path dir, String entityTarget) throws Exception {
|
|
||||||
String xml = "<?xml version=\"1.0\"?>"
|
|
||||||
+ "<!DOCTYPE foo [<!ENTITY xxe SYSTEM \"" + entityTarget + "\">]>"
|
|
||||||
+ "<office:document-content"
|
|
||||||
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
|
||||||
+ " xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
|
|
||||||
+ " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
|
|
||||||
+ "<office:body><office:spreadsheet>"
|
|
||||||
+ "<table:table><table:table-row><table:table-cell>"
|
|
||||||
+ "<text:p>&xxe;</text:p>"
|
|
||||||
+ "</table:table-cell></table:table-row></table:table>"
|
|
||||||
+ "</office:spreadsheet></office:body>"
|
|
||||||
+ "</office:document-content>";
|
|
||||||
return writeOdsZip(dir.resolve("malicious.ods"), xml);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates a minimal valid ODS ZIP containing a content.xml with the given cell value.
|
|
||||||
* cellValue must not contain XML metacharacters ({@code < > &}). */
|
|
||||||
private File buildValidOds(Path dir, String cellValue) throws Exception {
|
|
||||||
String xml = "<?xml version=\"1.0\"?>"
|
|
||||||
+ "<office:document-content"
|
|
||||||
+ " xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
|
|
||||||
+ " xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
|
|
||||||
+ " xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
|
|
||||||
+ "<office:body><office:spreadsheet>"
|
|
||||||
+ "<table:table><table:table-row><table:table-cell>"
|
|
||||||
+ "<text:p>" + cellValue + "</text:p>"
|
|
||||||
+ "</table:table-cell></table:table-row></table:table>"
|
|
||||||
+ "</office:spreadsheet></office:body>"
|
|
||||||
+ "</office:document-content>";
|
|
||||||
return writeOdsZip(dir.resolve("valid.ods"), xml);
|
|
||||||
}
|
|
||||||
|
|
||||||
private File writeOdsZip(Path destination, String contentXml) throws Exception {
|
|
||||||
try (OutputStream fos = Files.newOutputStream(destination);
|
|
||||||
ZipOutputStream zip = new ZipOutputStream(fos)) {
|
|
||||||
zip.putNextEntry(new ZipEntry("content.xml"));
|
|
||||||
zip.write(contentXml.getBytes(StandardCharsets.UTF_8));
|
|
||||||
zip.closeEntry();
|
|
||||||
}
|
|
||||||
return destination.toFile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PersonRegisterImporterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_upsertsPersonBySourceRef_withProvisionalFalse(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path xlsx = writePersons(tempDir, row(
|
||||||
|
"allemeyer-elsgard", "Allemeyer", "Elsgard", "Wöhler", "Nichte von Herbert", "False"));
|
||||||
|
|
||||||
|
new PersonRegisterImporter(personService).load(xlsx.toFile());
|
||||||
|
|
||||||
|
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService).upsertBySourceRef(captor.capture());
|
||||||
|
PersonUpsertCommand cmd = captor.getValue();
|
||||||
|
assertThat(cmd.sourceRef()).isEqualTo("allemeyer-elsgard");
|
||||||
|
assertThat(cmd.lastName()).isEqualTo("Allemeyer");
|
||||||
|
assertThat(cmd.firstName()).isEqualTo("Elsgard");
|
||||||
|
assertThat(cmd.maidenName()).isEqualTo("Wöhler");
|
||||||
|
assertThat(cmd.notes()).isEqualTo("Nichte von Herbert");
|
||||||
|
assertThat(cmd.provisional()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_parsesCapitalisedPythonBool_True(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path xlsx = writePersons(tempDir, row(
|
||||||
|
"noise-geschirr", "Geschirr", "", "", "", "True"));
|
||||||
|
|
||||||
|
new PersonRegisterImporter(personService).load(xlsx.toFile());
|
||||||
|
|
||||||
|
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService).upsertBySourceRef(captor.capture());
|
||||||
|
assertThat(captor.getValue().provisional()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_skipsRowWithBlankPersonId(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
Path xlsx = writePersons(tempDir, row("", "NoId", "", "", "", "False"));
|
||||||
|
|
||||||
|
new PersonRegisterImporter(personService).load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(personService, times(0)).upsertBySourceRef(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_returnsCountOfProcessedRows(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path xlsx = writePersons(tempDir,
|
||||||
|
row("a-one", "One", "A", "", "", "False"),
|
||||||
|
row("a-two", "Two", "B", "", "", "False"));
|
||||||
|
|
||||||
|
int processed = new PersonRegisterImporter(personService).load(xlsx.toFile());
|
||||||
|
|
||||||
|
assertThat(processed).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Person personOf(PersonUpsertCommand cmd) {
|
||||||
|
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef())
|
||||||
|
.firstName(cmd.firstName()).lastName(cmd.lastName())
|
||||||
|
.provisional(cmd.provisional()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> row(String personId, String lastName, String firstName,
|
||||||
|
String maidenName, String notes, String provisional) {
|
||||||
|
Map<String, String> r = new LinkedHashMap<>();
|
||||||
|
r.put("person_id", personId);
|
||||||
|
r.put("last_name", lastName);
|
||||||
|
r.put("first_name", firstName);
|
||||||
|
r.put("maiden_name", maidenName);
|
||||||
|
r.put("notes", notes);
|
||||||
|
r.put("provisional", provisional);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SafeVarargs
|
||||||
|
private Path writePersons(Path dir, Map<String, String>... rows) throws Exception {
|
||||||
|
Path xlsx = dir.resolve("canonical-persons.xlsx");
|
||||||
|
List<String> headers = List.of("person_id", "last_name", "first_name", "maiden_name", "notes", "provisional");
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = wb.createSheet("Sheet1");
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
for (int i = 0; i < headers.size(); i++) {
|
||||||
|
header.createCell(i).setCellValue(headers.get(i));
|
||||||
|
}
|
||||||
|
for (int r = 0; r < rows.length; r++) {
|
||||||
|
Row row = sheet.createRow(r + 1);
|
||||||
|
for (int c = 0; c < headers.size(); c++) {
|
||||||
|
row.createCell(c).setCellValue(rows[r].getOrDefault(headers.get(c), ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||||
|
wb.write(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return xlsx;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
import org.raddatz.familienarchiv.person.PersonUpsertCommand;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationType;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.RelationshipService;
|
||||||
|
import org.raddatz.familienarchiv.person.relationship.dto.CreateRelationshipRequest;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
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.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PersonTreeImporterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_upsertsTreePersonBySourceRef_withFamilyMemberFlag(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_002","firstName":"Elsgard","lastName":"Allemeyer","maidenName":"Wöhler",
|
||||||
|
"notes":"Nichte","birthYear":1920,"deathYear":1999,"familyMember":true,"personId":"allemeyer-elsgard"}
|
||||||
|
],"relationships":[]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
new PersonTreeImporter(personService, relationshipService)
|
||||||
|
.load(json.toFile());
|
||||||
|
|
||||||
|
ArgumentCaptor<PersonUpsertCommand> captor = ArgumentCaptor.forClass(PersonUpsertCommand.class);
|
||||||
|
verify(personService).upsertBySourceRef(captor.capture());
|
||||||
|
PersonUpsertCommand cmd = captor.getValue();
|
||||||
|
assertThat(cmd.sourceRef()).isEqualTo("allemeyer-elsgard");
|
||||||
|
assertThat(cmd.familyMember()).isTrue();
|
||||||
|
assertThat(cmd.provisional()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_createsRelationship_resolvingRowIdsToUpsertedPersons(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
UUID idA = UUID.randomUUID();
|
||||||
|
UUID idB = UUID.randomUUID();
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> {
|
||||||
|
PersonUpsertCommand c = inv.getArgument(0);
|
||||||
|
return Person.builder().id(c.sourceRef().equals("a") ? idA : idB)
|
||||||
|
.sourceRef(c.sourceRef()).lastName(c.lastName()).build();
|
||||||
|
});
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_a","lastName":"A","familyMember":true,"personId":"a"},
|
||||||
|
{"rowId":"row_b","lastName":"B","familyMember":true,"personId":"b"}
|
||||||
|
],"relationships":[
|
||||||
|
{"personId":"row_a","relatedPersonId":"row_b","type":"SPOUSE_OF","source":"verheiratet_mit"}
|
||||||
|
]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
new PersonTreeImporter(personService, relationshipService)
|
||||||
|
.load(json.toFile());
|
||||||
|
|
||||||
|
ArgumentCaptor<CreateRelationshipRequest> captor = ArgumentCaptor.forClass(CreateRelationshipRequest.class);
|
||||||
|
verify(relationshipService).addRelationship(eq(idA), captor.capture());
|
||||||
|
assertThat(captor.getValue().relatedPersonId()).isEqualTo(idB);
|
||||||
|
assertThat(captor.getValue().relationType()).isEqualTo(RelationType.SPOUSE_OF);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_swallowsDuplicateRelationship_forIdempotentReimport(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
when(personService.upsertBySourceRef(any()))
|
||||||
|
.thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
doThrow(DomainException.conflict(ErrorCode.DUPLICATE_RELATIONSHIP, "exists"))
|
||||||
|
.when(relationshipService).addRelationship(any(), any());
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_a","lastName":"A","familyMember":true,"personId":"a"},
|
||||||
|
{"rowId":"row_b","lastName":"B","familyMember":true,"personId":"b"}
|
||||||
|
],"relationships":[
|
||||||
|
{"personId":"row_a","relatedPersonId":"row_b","type":"SPOUSE_OF","source":"verheiratet_mit"}
|
||||||
|
]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
PersonTreeImporter importer = new PersonTreeImporter(personService, relationshipService);
|
||||||
|
|
||||||
|
// Must not propagate the conflict — re-import is idempotent.
|
||||||
|
importer.load(json.toFile());
|
||||||
|
|
||||||
|
verify(relationshipService).addRelationship(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_propagatesUnexpectedDomainException_fromAddRelationship(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
when(personService.upsertBySourceRef(any()))
|
||||||
|
.thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
// An unexpected ErrorCode (not DUPLICATE/CIRCULAR) must NOT be swallowed.
|
||||||
|
doThrow(DomainException.internal(ErrorCode.INTERNAL_ERROR, "boom"))
|
||||||
|
.when(relationshipService).addRelationship(any(), any());
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_a","lastName":"A","familyMember":true,"personId":"a"},
|
||||||
|
{"rowId":"row_b","lastName":"B","familyMember":true,"personId":"b"}
|
||||||
|
],"relationships":[
|
||||||
|
{"personId":"row_a","relatedPersonId":"row_b","type":"SPOUSE_OF","source":"verheiratet_mit"}
|
||||||
|
]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
PersonTreeImporter importer = new PersonTreeImporter(personService, relationshipService);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> importer.load(json.toFile()))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting("code").isEqualTo(ErrorCode.INTERNAL_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_skipsRelationship_whenRowIdUnresolved(@TempDir Path tempDir) throws Exception {
|
||||||
|
PersonService personService = mock(PersonService.class);
|
||||||
|
RelationshipService relationshipService = mock(RelationshipService.class);
|
||||||
|
when(personService.upsertBySourceRef(any())).thenAnswer(inv -> personOf(inv.getArgument(0)));
|
||||||
|
Path json = write(tempDir, """
|
||||||
|
{"persons":[
|
||||||
|
{"rowId":"row_a","lastName":"A","familyMember":true,"personId":"a"}
|
||||||
|
],"relationships":[
|
||||||
|
{"personId":"row_a","relatedPersonId":"row_ghost","type":"SPOUSE_OF","source":"x"}
|
||||||
|
]}
|
||||||
|
""");
|
||||||
|
|
||||||
|
new PersonTreeImporter(personService, relationshipService)
|
||||||
|
.load(json.toFile());
|
||||||
|
|
||||||
|
verify(relationshipService, org.mockito.Mockito.never()).addRelationship(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Person personOf(PersonUpsertCommand cmd) {
|
||||||
|
return Person.builder().id(UUID.randomUUID()).sourceRef(cmd.sourceRef()).lastName(cmd.lastName()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path write(Path dir, String json) throws Exception {
|
||||||
|
Path file = dir.resolve("canonical-persons-tree.json");
|
||||||
|
Files.writeString(file, json);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package org.raddatz.familienarchiv.importing;
|
||||||
|
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.tag.Tag;
|
||||||
|
import org.raddatz.familienarchiv.tag.TagService;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class TagTreeImporterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_upsertsRootTagWithNullParent(@TempDir Path tempDir) throws Exception {
|
||||||
|
TagService tagService = mock(TagService.class);
|
||||||
|
when(tagService.upsertBySourceRef(any(), any(), any()))
|
||||||
|
.thenAnswer(inv -> tagOf(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2)));
|
||||||
|
Path xlsx = writeTagTree(tempDir, List.<String[]>of(
|
||||||
|
new String[]{"Themen", "", "Themen"}));
|
||||||
|
|
||||||
|
new TagTreeImporter(tagService).load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(tagService).upsertBySourceRef("Themen", "Themen", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_resolvesParentByPath_forChildTag(@TempDir Path tempDir) throws Exception {
|
||||||
|
TagService tagService = mock(TagService.class);
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
when(tagService.upsertBySourceRef(eq("Themen"), eq("Themen"), isNull()))
|
||||||
|
.thenReturn(tagOf("Themen", "Themen", null, rootId));
|
||||||
|
when(tagService.upsertBySourceRef(eq("Themen/Brautbriefe"), eq("Brautbriefe"), eq(rootId)))
|
||||||
|
.thenReturn(tagOf("Themen/Brautbriefe", "Brautbriefe", rootId));
|
||||||
|
Path xlsx = writeTagTree(tempDir, List.<String[]>of(
|
||||||
|
new String[]{"Themen", "", "Themen"},
|
||||||
|
new String[]{"Themen/Brautbriefe", "Themen", "Brautbriefe"}));
|
||||||
|
|
||||||
|
new TagTreeImporter(tagService).load(xlsx.toFile());
|
||||||
|
|
||||||
|
verify(tagService).upsertBySourceRef("Themen/Brautbriefe", "Brautbriefe", rootId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void load_returnsCountOfProcessedRows(@TempDir Path tempDir) throws Exception {
|
||||||
|
TagService tagService = mock(TagService.class);
|
||||||
|
when(tagService.upsertBySourceRef(any(), any(), any()))
|
||||||
|
.thenAnswer(inv -> tagOf(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2)));
|
||||||
|
Path xlsx = writeTagTree(tempDir, List.<String[]>of(
|
||||||
|
new String[]{"Themen", "", "Themen"},
|
||||||
|
new String[]{"Themen/Brautbriefe", "Themen", "Brautbriefe"}));
|
||||||
|
|
||||||
|
int processed = new TagTreeImporter(tagService).load(xlsx.toFile());
|
||||||
|
|
||||||
|
assertThat(processed).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Tag tagOf(String sourceRef, String name, UUID parentId) {
|
||||||
|
return tagOf(sourceRef, name, parentId, UUID.randomUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Tag tagOf(String sourceRef, String name, UUID parentId, UUID id) {
|
||||||
|
return Tag.builder().id(id).sourceRef(sourceRef).name(name).parentId(parentId).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path writeTagTree(Path dir, List<String[]> rows) throws Exception {
|
||||||
|
Path xlsx = dir.resolve("canonical-tag-tree.xlsx");
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = wb.createSheet("Sheet1");
|
||||||
|
Row header = sheet.createRow(0);
|
||||||
|
header.createCell(0).setCellValue("tag_path");
|
||||||
|
header.createCell(1).setCellValue("parent_name");
|
||||||
|
header.createCell(2).setCellValue("tag_name");
|
||||||
|
for (int r = 0; r < rows.size(); r++) {
|
||||||
|
Row row = sheet.createRow(r + 1);
|
||||||
|
String[] values = rows.get(r);
|
||||||
|
for (int c = 0; c < values.length; c++) {
|
||||||
|
row.createCell(c).setCellValue(values[c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (OutputStream out = Files.newOutputStream(xlsx)) {
|
||||||
|
wb.write(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return xlsx;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(NotificationController.class)
|
@WebMvcTest(NotificationController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -141,7 +142,7 @@ class NotificationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/notifications/read-all"))
|
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ class NotificationControllerTest {
|
|||||||
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
|
AppUser user = AppUser.builder().id(USER_ID).email("testuser@example.com").build();
|
||||||
when(userService.findByEmail("testuser")).thenReturn(user);
|
when(userService.findByEmail("testuser")).thenReturn(user);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/notifications/read-all"))
|
mockMvc.perform(post("/api/notifications/read-all").with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(notificationService).markAllRead(USER_ID);
|
verify(notificationService).markAllRead(USER_ID);
|
||||||
@@ -161,7 +162,7 @@ class NotificationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void markOneRead_returns401_whenUnauthenticated() throws Exception {
|
void markOneRead_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read"))
|
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ class NotificationControllerTest {
|
|||||||
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
|
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
|
||||||
.when(notificationService).markRead(notifId, USER_ID);
|
.when(notificationService).markRead(notifId, USER_ID);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +257,7 @@ class NotificationControllerTest {
|
|||||||
.notifyOnReply(true).notifyOnMention(true).build();
|
.notifyOnReply(true).notifyOnMention(true).build();
|
||||||
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
|
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/users/me/notification-preferences")
|
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -275,7 +276,7 @@ class NotificationControllerTest {
|
|||||||
.notifyOnReply(true).notifyOnMention(false).build();
|
.notifyOnReply(true).notifyOnMention(false).build();
|
||||||
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
|
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/users/me/notification-preferences")
|
mockMvc.perform(put("/api/users/me/notification-preferences").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -337,7 +338,7 @@ class NotificationControllerTest {
|
|||||||
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
|
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
|
||||||
.when(notificationService).markRead(notifId, USER_ID);
|
.when(notificationService).markRead(notifId, USER_ID);
|
||||||
|
|
||||||
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read").with(csrf()))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(OcrController.class)
|
@WebMvcTest(OcrController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -66,7 +67,7 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId);
|
when(ocrService.startOcr(eq(docId), eq(ScriptType.TYPEWRITER), any(), anyBoolean())).thenReturn(jobId);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId)
|
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -80,7 +81,7 @@ class OcrControllerTest {
|
|||||||
when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean()))
|
when(ocrService.startOcr(eq(docId), any(), any(), anyBoolean()))
|
||||||
.thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded"));
|
.thenThrow(DomainException.badRequest(ErrorCode.OCR_DOCUMENT_NOT_UPLOADED, "Not uploaded"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/{id}/ocr", docId)
|
mockMvc.perform(post("/api/documents/{id}/ocr", docId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -127,7 +128,7 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId);
|
when(ocrBatchService.startBatch(eq(docIds), any())).thenReturn(jobId);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/batch")
|
mockMvc.perform(post("/api/ocr/batch").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(dto)))
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -179,14 +180,14 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void triggerTraining_returns401_whenUnauthenticated() throws Exception {
|
void triggerTraining_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void triggerTraining_returns403_whenNotAdmin() throws Exception {
|
void triggerTraining_returns403_whenNotAdmin() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ class OcrControllerTest {
|
|||||||
when(ocrTrainingService.triggerTraining(any()))
|
when(ocrTrainingService.triggerTraining(any()))
|
||||||
.thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running"));
|
.thenThrow(DomainException.conflict(ErrorCode.TRAINING_ALREADY_RUNNING, "Already running"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.andExpect(status().isConflict());
|
.andExpect(status().isConflict());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@ class OcrControllerTest {
|
|||||||
.blockCount(10).documentCount(3).modelName("german_kurrent").build();
|
.blockCount(10).documentCount(3).modelName("german_kurrent").build();
|
||||||
when(ocrTrainingService.triggerTraining(any())).thenReturn(run);
|
when(ocrTrainingService.triggerTraining(any())).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train"))
|
mockMvc.perform(post("/api/ocr/train").with(csrf()))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.status").value("DONE"))
|
.andExpect(jsonPath("$.status").value("DONE"))
|
||||||
.andExpect(jsonPath("$.blockCount").value(10));
|
.andExpect(jsonPath("$.blockCount").value(10));
|
||||||
@@ -365,7 +366,7 @@ class OcrControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ADMIN")
|
@WithMockUser(authorities = "ADMIN")
|
||||||
void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception {
|
void triggerSenderTraining_returns400_whenPersonIdIsNull() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":null}"))
|
.content("{\"personId\":null}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -373,7 +374,7 @@ class OcrControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
|
void triggerSenderTraining_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -382,7 +383,7 @@ class OcrControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void triggerSenderTraining_returns403_whenNotAdmin() throws Exception {
|
void triggerSenderTraining_returns403_whenNotAdmin() throws Exception {
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"personId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -395,7 +396,7 @@ class OcrControllerTest {
|
|||||||
when(senderModelService.triggerManualSenderTraining(unknownId))
|
when(senderModelService.triggerManualSenderTraining(unknownId))
|
||||||
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
|
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + unknownId + "\"}"))
|
.content("{\"personId\":\"" + unknownId + "\"}"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
@@ -410,7 +411,7 @@ class OcrControllerTest {
|
|||||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -426,7 +427,7 @@ class OcrControllerTest {
|
|||||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted())
|
.andExpect(status().isAccepted())
|
||||||
@@ -442,7 +443,7 @@ class OcrControllerTest {
|
|||||||
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
.personId(personId).blockCount(5).documentCount(0).modelName("sender_" + personId).build();
|
||||||
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
when(senderModelService.triggerManualSenderTraining(personId)).thenReturn(run);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ocr/train-sender")
|
mockMvc.perform(post("/api/ocr/train-sender").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"personId\":\"" + personId + "\"}"))
|
.content("{\"personId\":\"" + personId + "\"}"))
|
||||||
.andExpect(status().isAccepted());
|
.andExpect(status().isAccepted());
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
|
||||||
@WebMvcTest(PersonController.class)
|
@WebMvcTest(PersonController.class)
|
||||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
@@ -64,44 +65,144 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPersons_returns200_withEmptyList() throws Exception {
|
void getPersons_returns200_withEmptyPagedResult() throws Exception {
|
||||||
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
when(personService.search(any(), eq(0), eq(50), eq(null)))
|
||||||
|
.thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));
|
||||||
mockMvc.perform(get("/api/persons"))
|
mockMvc.perform(get("/api/persons"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.items").isArray())
|
||||||
|
.andExpect(jsonPath("$.totalElements").value(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPersons_delegatesQueryParam_toService() throws Exception {
|
void getPersons_delegatesQueryParam_toService() throws Exception {
|
||||||
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
||||||
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
when(personService.search(any(), eq(0), eq(50), eq("Hans")))
|
||||||
|
.thenReturn(PersonSearchResult.paged(List.of(dto), 0, 50, 1));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/persons").param("q", "Hans"))
|
mockMvc.perform(get("/api/persons").param("q", "Hans"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$[0].firstName").value("Hans"));
|
.andExpect(jsonPath("$.items[0].firstName").value("Hans"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPersons_delegatesTopByDocumentCount_whenSortAndSizeGiven() throws Exception {
|
void getPersons_passesFilterParams_toService() throws Exception {
|
||||||
|
ArgumentCaptor<PersonFilter> filterCaptor = ArgumentCaptor.forClass(PersonFilter.class);
|
||||||
|
when(personService.search(filterCaptor.capture(), eq(0), eq(50), eq(null)))
|
||||||
|
.thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons")
|
||||||
|
.param("type", "INSTITUTION")
|
||||||
|
.param("familyOnly", "true")
|
||||||
|
.param("hasDocuments", "true")
|
||||||
|
.param("provisional", "false"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
PersonFilter captured = filterCaptor.getValue();
|
||||||
|
assertThat(captured.type()).isEqualTo(PersonType.INSTITUTION);
|
||||||
|
assertThat(captured.familyOnly()).isTrue();
|
||||||
|
assertThat(captured.hasDocuments()).isTrue();
|
||||||
|
assertThat(captured.provisional()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void getPersons_defaultsToReaderDefault_whenNoReviewFlag() throws Exception {
|
||||||
|
ArgumentCaptor<PersonFilter> filterCaptor = ArgumentCaptor.forClass(PersonFilter.class);
|
||||||
|
when(personService.search(filterCaptor.capture(), eq(0), eq(50), eq(null)))
|
||||||
|
.thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons")).andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertThat(filterCaptor.getValue().readerDefault()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void getPersons_dropsReaderDefault_whenReviewFlagSet() throws Exception {
|
||||||
|
ArgumentCaptor<PersonFilter> filterCaptor = ArgumentCaptor.forClass(PersonFilter.class);
|
||||||
|
when(personService.search(filterCaptor.capture(), eq(0), eq(50), eq(null)))
|
||||||
|
.thenReturn(PersonSearchResult.paged(Collections.emptyList(), 0, 50, 0));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons").param("review", "true")).andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertThat(filterCaptor.getValue().readerDefault()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void getPersons_passesPageAndSize_toService() throws Exception {
|
||||||
|
when(personService.search(any(), eq(2), eq(25), eq(null)))
|
||||||
|
.thenReturn(PersonSearchResult.paged(Collections.emptyList(), 2, 25, 0));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons").param("page", "2").param("size", "25"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(personService).search(any(), eq(2), eq(25), eq(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void getPersons_returns400_whenSizeIsZero() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons").param("size", "0"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void getPersons_returns400_whenSizeExceeds100() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons").param("size", "101"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void getPersons_returns400_whenPageIsNegative() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons").param("page", "-1"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void getPersons_delegatesTopByDocumentCount_whenSortGiven() throws Exception {
|
||||||
PersonSummaryDTO top = mockPersonSummary("Käthe", "Raddatz");
|
PersonSummaryDTO top = mockPersonSummary("Käthe", "Raddatz");
|
||||||
when(personService.findTopByDocumentCount(4)).thenReturn(List.of(top));
|
when(personService.findTopByDocumentCount(4)).thenReturn(List.of(top));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "4"))
|
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "4"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$[0].firstName").value("Käthe"));
|
.andExpect(jsonPath("$.items[0].firstName").value("Käthe"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void getPersons_capsTopByDocumentCount_atFifty() throws Exception {
|
void getPersons_topByDocumentCount_isNonPaged_totalElementsEqualsReturnedCount() throws Exception {
|
||||||
ArgumentCaptor<Integer> sizeCaptor = ArgumentCaptor.forClass(Integer.class);
|
// The top-N dashboard path is deliberately NON-paged: it returns the complete result
|
||||||
when(personService.findTopByDocumentCount(sizeCaptor.capture())).thenReturn(Collections.emptyList());
|
// (no further page exists), so totalElements equals the number of rows returned and
|
||||||
|
// totalPages is 1. Pinned so nobody "fixes" it into a misleading paged total.
|
||||||
|
when(personService.findTopByDocumentCount(50))
|
||||||
|
.thenReturn(List.of(mockPersonSummary("Käthe", "Raddatz"),
|
||||||
|
mockPersonSummary("Hans", "Müller")));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/persons").param("sort", "documentCount").param("size", "999"))
|
mockMvc.perform(get("/api/persons").param("sort", "documentCount"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.items.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.totalElements").value(2))
|
||||||
|
.andExpect(jsonPath("$.pageNumber").value(0))
|
||||||
|
.andExpect(jsonPath("$.pageSize").value(2))
|
||||||
|
.andExpect(jsonPath("$.totalPages").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
assertThat(sizeCaptor.getValue()).isEqualTo(50);
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void getPersons_topByDocumentCount_emptyResult_reportsZeroPages() throws Exception {
|
||||||
|
when(personService.findTopByDocumentCount(50)).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons").param("sort", "documentCount"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.totalElements").value(0))
|
||||||
|
.andExpect(jsonPath("$.totalPages").value(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
|
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
|
||||||
@@ -116,6 +217,7 @@ class PersonControllerTest {
|
|||||||
public Integer getDeathYear() { return null; }
|
public Integer getDeathYear() { return null; }
|
||||||
public String getNotes() { return null; }
|
public String getNotes() { return null; }
|
||||||
public boolean isFamilyMember() { return false; }
|
public boolean isFamilyMember() { return false; }
|
||||||
|
public boolean isProvisional() { return false; }
|
||||||
public long getDocumentCount() { return 0; }
|
public long getDocumentCount() { return 0; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -217,7 +319,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createPerson_returns401_whenUnauthenticated() throws Exception {
|
void createPerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -226,7 +328,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -235,7 +337,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
void createPerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\" \",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -244,7 +346,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -253,7 +355,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -265,7 +367,7 @@ class PersonControllerTest {
|
|||||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -278,7 +380,7 @@ class PersonControllerTest {
|
|||||||
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).lastName("Verlag GmbH").build();
|
||||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
.content("{\"lastName\":\"Verlag GmbH\",\"personType\":\"INSTITUTION\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -293,7 +395,7 @@ class PersonControllerTest {
|
|||||||
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
when(personService.createPerson(captor.capture())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"title\":\" Prof. \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
@@ -307,7 +409,7 @@ class PersonControllerTest {
|
|||||||
when(personService.createPerson(any())).thenThrow(
|
when(personService.createPerson(any())).thenThrow(
|
||||||
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
.content("{\"lastName\":\"Müller\",\"personType\":\"SKIP\"}"))
|
||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
@@ -318,7 +420,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updatePerson_returns401_whenUnauthenticated() throws Exception {
|
void updatePerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -327,7 +429,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenPersonTypeIsPerson_andFirstNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -336,7 +438,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -349,7 +451,7 @@ class PersonControllerTest {
|
|||||||
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
|
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
|
||||||
when(personService.updatePerson(eq(id), any())).thenReturn(updated);
|
when(personService.updatePerson(eq(id), any())).thenReturn(updated);
|
||||||
|
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -360,7 +462,7 @@ class PersonControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mergePerson_returns401_whenUnauthenticated() throws Exception {
|
void mergePerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
@@ -369,7 +471,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
|
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{}"))
|
.content("{}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -378,7 +480,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
|
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\" \"}"))
|
.content("{\"targetPersonId\":\" \"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -390,19 +492,74 @@ class PersonControllerTest {
|
|||||||
UUID sourceId = UUID.randomUUID();
|
UUID sourceId = UUID.randomUUID();
|
||||||
UUID targetId = UUID.randomUUID();
|
UUID targetId = UUID.randomUUID();
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", sourceId)
|
mockMvc.perform(post("/api/persons/{id}/merge", sourceId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
|
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/persons/{id}/confirm ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void confirmPerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/persons/{id}/confirm", UUID.randomUUID()).with(csrf()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void confirmPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/persons/{id}/confirm", UUID.randomUUID()).with(csrf()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void confirmPerson_returns200_andClearsProvisional() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person confirmed = Person.builder().id(id).firstName("Bald").lastName("Bestaetigt").provisional(false).build();
|
||||||
|
when(personService.confirmPerson(id)).thenReturn(confirmed);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/persons/{id}/confirm", id).with(csrf()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.provisional").value(false));
|
||||||
|
|
||||||
|
verify(personService).confirmPerson(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DELETE /api/persons/{id} ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletePerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/persons/{id}", UUID.randomUUID()).with(csrf()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void deletePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/persons/{id}", UUID.randomUUID()).with(csrf()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void deletePerson_returns204_whenValid() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/persons/{id}", id).with(csrf()))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
verify(personService).deletePerson(id);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PUT /api/persons/{id} — lastName blank branch ────────────────────────
|
// ─── PUT /api/persons/{id} — lastName blank branch ────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -418,7 +575,7 @@ class PersonControllerTest {
|
|||||||
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
|
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
|
||||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||||
@@ -436,7 +593,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
|
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
|
||||||
String oversizedNotes = "x".repeat(5001);
|
String oversizedNotes = "x".repeat(5001);
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -447,7 +604,7 @@ class PersonControllerTest {
|
|||||||
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
|
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
|
||||||
String oversizedFirstName = "x".repeat(101);
|
String oversizedFirstName = "x".repeat(101);
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
mockMvc.perform(put("/api/persons/{id}", id)
|
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -458,7 +615,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons")
|
mockMvc.perform(post("/api/persons").with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -467,7 +624,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -476,7 +633,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -507,7 +664,7 @@ class PersonControllerTest {
|
|||||||
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
|
.id(UUID.randomUUID()).lastName("de Gruyter").type(PersonNameAliasType.BIRTH).sortOrder(0).build();
|
||||||
when(personService.addAlias(eq(personId), any())).thenReturn(saved);
|
when(personService.addAlias(eq(personId), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", personId)
|
mockMvc.perform(post("/api/persons/{id}/aliases", personId).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -517,7 +674,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void addAlias_returns403_withoutWritePermission() throws Exception {
|
void addAlias_returns403_withoutWritePermission() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
.content("{\"lastName\":\"de Gruyter\",\"type\":\"BIRTH\"}"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
@@ -531,7 +688,7 @@ class PersonControllerTest {
|
|||||||
UUID personId = UUID.randomUUID();
|
UUID personId = UUID.randomUUID();
|
||||||
UUID aliasId = UUID.randomUUID();
|
UUID aliasId = UUID.randomUUID();
|
||||||
|
|
||||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId))
|
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", personId, aliasId).with(csrf()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
verify(personService).removeAlias(personId, aliasId);
|
verify(personService).removeAlias(personId, aliasId);
|
||||||
@@ -540,14 +697,14 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "READ_ALL")
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
void removeAlias_returns403_withoutWritePermission() throws Exception {
|
void removeAlias_returns403_withoutWritePermission() throws Exception {
|
||||||
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()))
|
mockMvc.perform(delete("/api/persons/{id}/aliases/{aliasId}", UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void addAlias_returns400_whenLastNameIsBlank() throws Exception {
|
void addAlias_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
|
.content("{\"lastName\":\"\",\"type\":\"BIRTH\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
@@ -556,7 +713,7 @@ class PersonControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
void addAlias_returns400_whenTypeIsNull() throws Exception {
|
void addAlias_returns400_whenTypeIsNull() throws Exception {
|
||||||
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID())
|
mockMvc.perform(post("/api/persons/{id}/aliases", UUID.randomUUID()).with(csrf())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"lastName\":\"de Gruyter\"}"))
|
.content("{\"lastName\":\"de Gruyter\"}"))
|
||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package org.raddatz.familienarchiv.person;
|
||||||
|
|
||||||
|
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.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
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.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PersonImportUpsertTest {
|
||||||
|
|
||||||
|
@Mock PersonRepository personRepository;
|
||||||
|
@Mock PersonNameAliasRepository aliasRepository;
|
||||||
|
@InjectMocks PersonService personService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsertBySourceRef_insertsNewPerson_whenSourceRefUnknown() {
|
||||||
|
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||||
|
.sourceRef("clara-cram").firstName("Clara").lastName("Cram")
|
||||||
|
.personType(PersonType.PERSON).provisional(false).build();
|
||||||
|
|
||||||
|
Person result = personService.upsertBySourceRef(cmd);
|
||||||
|
|
||||||
|
assertThat(result.getSourceRef()).isEqualTo("clara-cram");
|
||||||
|
assertThat(result.getFirstName()).isEqualTo("Clara");
|
||||||
|
assertThat(result.getLastName()).isEqualTo("Cram");
|
||||||
|
assertThat(result.isProvisional()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsertBySourceRef_updatesInPlace_whenSourceRefExists() {
|
||||||
|
Person existing = Person.builder()
|
||||||
|
.id(UUID.randomUUID()).sourceRef("clara-cram")
|
||||||
|
.firstName("Clara").lastName("Cram").build();
|
||||||
|
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||||
|
.sourceRef("clara-cram").firstName("Clara").lastName("Cram")
|
||||||
|
.notes("Updated note").personType(PersonType.PERSON).provisional(false).build();
|
||||||
|
|
||||||
|
personService.upsertBySourceRef(cmd);
|
||||||
|
|
||||||
|
verify(personRepository).save(argThat(p -> p.getId().equals(existing.getId())));
|
||||||
|
verify(personRepository, never()).save(argThat(p -> p.getId() == null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsertBySourceRef_preservesHumanEditedNonBlankFields() {
|
||||||
|
// A human renamed the maiden-name register person and added notes in-app.
|
||||||
|
Person humanEdited = Person.builder()
|
||||||
|
.id(UUID.randomUUID()).sourceRef("clara-cram")
|
||||||
|
.firstName("Klara").lastName("Cram-Müller").notes("Verified by Marcel").build();
|
||||||
|
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(humanEdited));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||||
|
.sourceRef("clara-cram").firstName("Clara").lastName("Cram")
|
||||||
|
.notes("Auto note").personType(PersonType.PERSON).provisional(false).build();
|
||||||
|
|
||||||
|
Person result = personService.upsertBySourceRef(cmd);
|
||||||
|
|
||||||
|
// Human edits survive the re-import.
|
||||||
|
assertThat(result.getFirstName()).isEqualTo("Klara");
|
||||||
|
assertThat(result.getLastName()).isEqualTo("Cram-Müller");
|
||||||
|
assertThat(result.getNotes()).isEqualTo("Verified by Marcel");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsertBySourceRef_fillsOnlyBlankFields_onReimport() {
|
||||||
|
Person existing = Person.builder()
|
||||||
|
.id(UUID.randomUUID()).sourceRef("clara-cram")
|
||||||
|
.firstName("Clara").lastName("Cram").notes(null).build();
|
||||||
|
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||||
|
.sourceRef("clara-cram").firstName("Clara").lastName("Cram")
|
||||||
|
.notes("Nichte von Herbert").personType(PersonType.PERSON).provisional(false).build();
|
||||||
|
|
||||||
|
Person result = personService.upsertBySourceRef(cmd);
|
||||||
|
|
||||||
|
// Blank field gets filled by canonical value.
|
||||||
|
assertThat(result.getNotes()).isEqualTo("Nichte von Herbert");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsertBySourceRef_fillsBlankYears_butPreservesHumanEditedYears_onReimport() {
|
||||||
|
// Existing has a human-set birthYear and a blank deathYear.
|
||||||
|
Person existing = Person.builder()
|
||||||
|
.id(UUID.randomUUID()).sourceRef("clara-cram")
|
||||||
|
.lastName("Cram").birthYear(1890).deathYear(null).build();
|
||||||
|
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||||
|
.sourceRef("clara-cram").lastName("Cram")
|
||||||
|
.birthYear(1888).deathYear(1965)
|
||||||
|
.personType(PersonType.PERSON).provisional(false).build();
|
||||||
|
|
||||||
|
Person result = personService.upsertBySourceRef(cmd);
|
||||||
|
|
||||||
|
assertThat(result.getBirthYear()).isEqualTo(1890); // human value kept
|
||||||
|
assertThat(result.getDeathYear()).isEqualTo(1965); // blank filled from canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsertBySourceRef_neverFlipsProvisionalBackToTrue_onceHumanConfirmed() {
|
||||||
|
// A human confirmed this provisional importer-created person (provisional -> false).
|
||||||
|
Person confirmed = Person.builder()
|
||||||
|
.id(UUID.randomUUID()).sourceRef("schwester-hanni")
|
||||||
|
.firstName(null).lastName("Schwester Hanni").provisional(false).build();
|
||||||
|
when(personRepository.findBySourceRef("schwester-hanni")).thenReturn(Optional.of(confirmed));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||||
|
.sourceRef("schwester-hanni").lastName("Schwester Hanni")
|
||||||
|
.personType(PersonType.PERSON).provisional(true).build();
|
||||||
|
|
||||||
|
Person result = personService.upsertBySourceRef(cmd);
|
||||||
|
|
||||||
|
assertThat(result.isProvisional()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upsertBySourceRef_setsProvisionalTrue_forNewProvisionalPerson() {
|
||||||
|
when(personRepository.findBySourceRef("noise-geschirr")).thenReturn(Optional.empty());
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||||
|
.sourceRef("noise-geschirr").lastName("Tante Tüten")
|
||||||
|
.personType(PersonType.PERSON).provisional(true).build();
|
||||||
|
|
||||||
|
Person result = personService.upsertBySourceRef(cmd);
|
||||||
|
|
||||||
|
assertThat(result.isProvisional()).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -463,4 +463,213 @@ class PersonRepositoryTest {
|
|||||||
assertThat(result).hasSize(1);
|
assertThat(result).hasSize(1);
|
||||||
assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages");
|
assertThat(result.get(0).getLastName()).isEqualTo("Gesellschafter des Verlages");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── #671: provisional must be SELECTed in all three native projections ───
|
||||||
|
// Adding isProvisional() to the interface compiles even if a native query forgets
|
||||||
|
// to SELECT p.provisional — it then silently returns false. These tests are the only
|
||||||
|
// guard against that trap, so they must run against real Postgres.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllWithDocumentCount_projectsProvisionalTrue() {
|
||||||
|
personRepository.save(Person.builder()
|
||||||
|
.firstName("Inferred").lastName("Person").provisional(true).build());
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();
|
||||||
|
|
||||||
|
assertThat(result).anyMatch(PersonSummaryDTO::isProvisional);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchWithDocumentCount_projectsProvisionalTrue() {
|
||||||
|
personRepository.save(Person.builder()
|
||||||
|
.firstName("Provisorisch").lastName("Müller").provisional(true).build());
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("Provisorisch");
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).isProvisional()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findTopByDocumentCount_projectsProvisionalTrue() {
|
||||||
|
Person provisional = personRepository.save(Person.builder()
|
||||||
|
.firstName("Top").lastName("Provisional").provisional(true).build());
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief").originalFilename("b.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(provisional).build());
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> result = personRepository.findTopByDocumentCount(10);
|
||||||
|
|
||||||
|
PersonSummaryDTO summary = result.stream()
|
||||||
|
.filter(p -> p.getId().equals(provisional.getId())).findFirst().orElseThrow();
|
||||||
|
assertThat(summary.isProvisional()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── #667: filter-aware paged slice + paired COUNT (Postgres-only) ────────
|
||||||
|
// The slice query (findByFilter) and the count query (countByFilter) MUST share one
|
||||||
|
// WHERE clause so totalElements can never drift from the rendered page. These tests run
|
||||||
|
// against real Postgres because the slice ORDER BY uses a computed alias that fails on H2.
|
||||||
|
|
||||||
|
private void seedDirectoryFixture() {
|
||||||
|
// Register family member, no documents — visible by reader default (familyMember)
|
||||||
|
personRepository.save(Person.builder().firstName("Karl").lastName("Register").familyMember(true).build());
|
||||||
|
// Person with one document — visible by reader default (documentCount > 0)
|
||||||
|
Person hasDoc = personRepository.save(Person.builder().firstName("Doku").lastName("Person").build());
|
||||||
|
documentRepository.save(Document.builder().title("B").originalFilename("b.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(hasDoc).build());
|
||||||
|
// Provisional, zero-document, non-family — hidden by reader default
|
||||||
|
personRepository.save(Person.builder().firstName("Unbe").lastName("Staetigt").provisional(true).build());
|
||||||
|
// An institution with no documents, non-family, non-provisional
|
||||||
|
personRepository.save(Person.builder().lastName("Verlag GmbH").personType(PersonType.INSTITUTION).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFilter_readerDefault_returnsOnlyFamilyOrWithDocuments() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||||
|
null, null, null, null, true, null, 50, 0);
|
||||||
|
|
||||||
|
assertThat(slice).extracting(PersonSummaryDTO::getLastName)
|
||||||
|
.containsExactlyInAnyOrder("Register", "Person");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countByFilter_readerDefault_matchesSliceSize() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
long count = personRepository.countByFilter(null, null, null, null, true, null);
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFilter_showAll_returnsEveryone() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||||
|
null, null, null, null, false, null, 50, 0);
|
||||||
|
|
||||||
|
assertThat(slice).hasSize(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFilter_typeInstitution_returnsOnlyInstitutions() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||||
|
"INSTITUTION", null, null, null, false, null, 50, 0);
|
||||||
|
|
||||||
|
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFilter_familyOnly_returnsOnlyFamilyMembers() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||||
|
null, true, null, null, false, null, 50, 0);
|
||||||
|
|
||||||
|
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Register");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFilter_hasDocuments_returnsOnlyPersonsWithDocuments() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||||
|
null, null, true, null, false, null, 50, 0);
|
||||||
|
|
||||||
|
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Person");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFilter_provisionalTrue_returnsOnlyProvisional() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||||
|
null, null, null, true, false, null, 50, 0);
|
||||||
|
|
||||||
|
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Staetigt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFilter_combinedFilters_andTogether() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
// family + has-documents → intersection is empty (Register has no docs, Doku is not family)
|
||||||
|
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||||
|
null, true, true, null, false, null, 50, 0);
|
||||||
|
|
||||||
|
assertThat(slice).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFilter_query_combinesWithFilters() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||||
|
null, null, null, null, false, "Verlag", 50, 0);
|
||||||
|
|
||||||
|
assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFilter_pageBeyondRange_returnsEmptySlice() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||||
|
null, null, null, null, false, null, 50, 999 * 50);
|
||||||
|
|
||||||
|
assertThat(slice).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFilter_respectsPageSize() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> firstPage = personRepository.findByFilter(
|
||||||
|
null, null, null, null, false, null, 2, 0);
|
||||||
|
List<PersonSummaryDTO> secondPage = personRepository.findByFilter(
|
||||||
|
null, null, null, null, false, null, 2, 2);
|
||||||
|
|
||||||
|
assertThat(firstPage).hasSize(2);
|
||||||
|
assertThat(secondPage).hasSize(2);
|
||||||
|
assertThat(firstPage).extracting(PersonSummaryDTO::getId)
|
||||||
|
.doesNotContainAnyElementsOf(secondPage.stream().map(PersonSummaryDTO::getId).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countByFilter_typeInstitution_matchesSlice() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
long count = personRepository.countByFilter("INSTITUTION", null, null, null, false, null);
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countByFilter_query_matchesSliceSize() {
|
||||||
|
// The whole point of the shared FILTER_WHERE is that the slice and the count can never
|
||||||
|
// drift. Pin the query (LIKE) path explicitly: countByFilter must equal the slice size
|
||||||
|
// so a future edit to one query's LIKE clause is caught.
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||||
|
null, null, null, null, false, "Verlag", 50, 0);
|
||||||
|
long count = personRepository.countByFilter(null, null, null, null, false, "Verlag");
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(slice.size());
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFilter_projectsDocumentCount() {
|
||||||
|
seedDirectoryFixture();
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||||
|
null, null, true, null, false, null, 50, 0);
|
||||||
|
|
||||||
|
assertThat(slice.get(0).getDocumentCount()).isEqualTo(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package org.raddatz.familienarchiv.person;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.document.Document;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.person.Person;
|
import org.raddatz.familienarchiv.person.Person;
|
||||||
import org.raddatz.familienarchiv.person.PersonType;
|
import org.raddatz.familienarchiv.person.PersonType;
|
||||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||||
@@ -13,6 +16,11 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
@@ -24,6 +32,9 @@ class PersonServiceIntegrationTest {
|
|||||||
@MockitoBean S3Client s3Client;
|
@MockitoBean S3Client s3Client;
|
||||||
@Autowired PersonService personService;
|
@Autowired PersonService personService;
|
||||||
@Autowired PersonRepository personRepository;
|
@Autowired PersonRepository personRepository;
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@PersistenceContext EntityManager entityManager;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findOrCreateByAlias_skipReturnsNull_noRecordCreated() {
|
void findOrCreateByAlias_skipReturnsNull_noRecordCreated() {
|
||||||
@@ -63,4 +74,97 @@ class PersonServiceIntegrationTest {
|
|||||||
assertThat(result.getFirstName()).isEqualTo("Clara");
|
assertThat(result.getFirstName()).isEqualTo("Clara");
|
||||||
assertThat(result.getLastName()).isEqualTo("Cram");
|
assertThat(result.getLastName()).isEqualTo("Cram");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── #667: confirm round-trip + reader-default semantics ──────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_readerDefault_hidesProvisionalZeroDocumentPerson() {
|
||||||
|
personRepository.save(Person.builder()
|
||||||
|
.firstName("Unbe").lastName("Staetigt").provisional(true).build());
|
||||||
|
|
||||||
|
PersonSearchResult result = personService.search(PersonFilter.cleanDefault(), 0, 50, null);
|
||||||
|
|
||||||
|
assertThat(result.items()).noneMatch(p -> p.getLastName().equals("Staetigt"));
|
||||||
|
assertThat(result.totalElements()).isEqualTo(result.items().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_showAll_includesProvisionalZeroDocumentPerson() {
|
||||||
|
personRepository.save(Person.builder()
|
||||||
|
.firstName("Unbe").lastName("Staetigt").provisional(true).build());
|
||||||
|
|
||||||
|
PersonSearchResult result = personService.search(PersonFilter.showAll(), 0, 50, null);
|
||||||
|
|
||||||
|
assertThat(result.items()).anyMatch(p -> p.getLastName().equals("Staetigt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void confirmPerson_clearsProvisional_andShowAllTreatsItAsConfirmed() {
|
||||||
|
Person provisional = personRepository.save(Person.builder()
|
||||||
|
.firstName("Bald").lastName("Bestaetigt").provisional(true).build());
|
||||||
|
|
||||||
|
personService.confirmPerson(provisional.getId());
|
||||||
|
|
||||||
|
Person reloaded = personRepository.findById(provisional.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.isProvisional()).isFalse();
|
||||||
|
|
||||||
|
PersonSearchResult showAll = personService.search(PersonFilter.showAll(), 0, 50, null);
|
||||||
|
assertThat(showAll.items())
|
||||||
|
.filteredOn(p -> p.getId().equals(provisional.getId()))
|
||||||
|
.allMatch(p -> !p.isProvisional());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletePerson_removesPerson() {
|
||||||
|
Person target = personRepository.save(Person.builder()
|
||||||
|
.firstName("Weg").lastName("Person").provisional(true).build());
|
||||||
|
|
||||||
|
personService.deletePerson(target.getId());
|
||||||
|
|
||||||
|
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.
|
||||||
|
Person target = personRepository.save(Person.builder()
|
||||||
|
.firstName("Weg").lastName("Person").provisional(true).build());
|
||||||
|
Person bystander = personRepository.save(Person.builder()
|
||||||
|
.firstName("Bleibt").lastName("Hier").build());
|
||||||
|
|
||||||
|
Document sent = documentRepository.save(Document.builder()
|
||||||
|
.title("Sent letter").originalFilename("sent.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).sender(target).build());
|
||||||
|
Document received = documentRepository.save(Document.builder()
|
||||||
|
.title("Received letter").originalFilename("received.pdf")
|
||||||
|
.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.
|
||||||
|
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.
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||||
|
|
||||||
|
Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
|
||||||
|
assertThat(reloadedSent.getSender()).isNull();
|
||||||
|
|
||||||
|
Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
|
||||||
|
assertThat(reloadedReceived.getReceivers())
|
||||||
|
.noneMatch(p -> p.getId().equals(target.getId()));
|
||||||
|
// The other person and the documents themselves survive the delete.
|
||||||
|
assertThat(personRepository.findById(bystander.getId())).isPresent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,33 +58,109 @@ class PersonServiceTest {
|
|||||||
assertThat(personService.getById(id)).isEqualTo(person);
|
assertThat(personService.getById(id)).isEqualTo(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── findAll ─────────────────────────────────────────────────────────────
|
// ─── #667: search (filter + pagination) ──────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAll_returnsAll_whenQueryIsNull() {
|
void search_returnsPagedResult_withTotalsFromCountQuery() {
|
||||||
List<PersonSummaryDTO> expected = List.of();
|
PersonFilter filter = PersonFilter.cleanDefault();
|
||||||
when(personRepository.findAllWithDocumentCount()).thenReturn(expected);
|
when(personRepository.countByFilter(null, null, null, null, true, null)).thenReturn(120L);
|
||||||
|
when(personRepository.findByFilter(null, null, null, null, true, null, 50, 0))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
assertThat(personService.findAll(null)).isEqualTo(expected);
|
PersonSearchResult result = personService.search(filter, 0, 50, null);
|
||||||
verify(personRepository).findAllWithDocumentCount();
|
|
||||||
verify(personRepository, never()).searchWithDocumentCount(any());
|
assertThat(result.totalElements()).isEqualTo(120L);
|
||||||
|
assertThat(result.pageNumber()).isEqualTo(0);
|
||||||
|
assertThat(result.pageSize()).isEqualTo(50);
|
||||||
|
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAll_returnsEmpty_whenQueryIsWhitespaceOnly() {
|
void search_passesTypeAsEnumName_toRepository() {
|
||||||
assertThat(personService.findAll(" ")).isEmpty();
|
PersonFilter filter = PersonFilter.builder().type(PersonType.INSTITUTION).build();
|
||||||
verify(personRepository, never()).findAllWithDocumentCount();
|
when(personRepository.countByFilter("INSTITUTION", null, null, null, false, null)).thenReturn(0L);
|
||||||
verify(personRepository, never()).searchWithDocumentCount(any());
|
when(personRepository.findByFilter("INSTITUTION", null, null, null, false, null, 50, 0))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
personService.search(filter, 0, 50, null);
|
||||||
|
|
||||||
|
verify(personRepository).findByFilter("INSTITUTION", null, null, null, false, null, 50, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findAll_searchesByName_whenQueryIsNonBlank() {
|
void search_computesOffset_fromPageAndSize() {
|
||||||
List<PersonSummaryDTO> expected = List.of();
|
PersonFilter filter = PersonFilter.showAll();
|
||||||
when(personRepository.searchWithDocumentCount("Anna")).thenReturn(expected);
|
when(personRepository.countByFilter(null, null, null, null, false, null)).thenReturn(0L);
|
||||||
|
when(personRepository.findByFilter(null, null, null, null, false, null, 20, 40))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
assertThat(personService.findAll("Anna")).isEqualTo(expected);
|
personService.search(filter, 2, 20, null); // offset = page * size = 40
|
||||||
verify(personRepository).searchWithDocumentCount("Anna");
|
|
||||||
verify(personRepository, never()).findAllWithDocumentCount();
|
verify(personRepository).findByFilter(null, null, null, null, false, null, 20, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_trimsBlankQueryToNull() {
|
||||||
|
PersonFilter filter = PersonFilter.showAll();
|
||||||
|
when(personRepository.countByFilter(null, null, null, null, false, null)).thenReturn(0L);
|
||||||
|
when(personRepository.findByFilter(null, null, null, null, false, null, 50, 0))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
personService.search(filter, 0, 50, " ");
|
||||||
|
|
||||||
|
verify(personRepository).findByFilter(null, null, null, null, false, null, 50, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── #667: confirmPerson ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void confirmPerson_clearsProvisionalFlag() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person provisional = Person.builder().id(id).firstName("Inferred").lastName("Person").provisional(true).build();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.of(provisional));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Person result = personService.confirmPerson(id);
|
||||||
|
|
||||||
|
assertThat(result.isProvisional()).isFalse();
|
||||||
|
verify(personRepository).save(argThat(p -> !p.isProvisional()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void confirmPerson_throwsNotFound_whenMissing() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> personService.confirmPerson(id))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
|
.isEqualTo(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── #667: deletePerson ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletePerson_deletes_whenPersonExists() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person person = Person.builder().id(id).firstName("Weg").lastName("Person").build();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||||
|
|
||||||
|
personService.deletePerson(id);
|
||||||
|
|
||||||
|
verify(personRepository).reassignSenderToNull(id);
|
||||||
|
verify(personRepository).deleteReceiverReferences(id);
|
||||||
|
verify(personRepository).deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deletePerson_throwsNotFound_whenMissing() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> personService.deletePerson(id))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
|
.isEqualTo(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── createPerson ─────────────────────────────────────────────────────────
|
// ─── createPerson ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user