Compare commits
42 Commits
3d929c55c3
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de1a36a0d6 | ||
|
|
d56a9eb401 | ||
|
|
5dc1bf6bfb | ||
|
|
935c8eadd2 | ||
|
|
453e709a7c | ||
|
|
74fdc0cef7 | ||
|
|
bde1237358 | ||
|
|
788a804810 | ||
|
|
62b96f718f | ||
|
|
6ed5151e50 | ||
|
|
3a7c86fc87 | ||
|
|
1226bd0a07 | ||
|
|
00a00b2c87 | ||
|
|
cc841a7a4c | ||
|
|
513cdb7a4d | ||
|
|
595007213c | ||
|
|
45001f042a | ||
|
|
d11378c254 | ||
|
|
f64acbc697 | ||
|
|
75e48f2922 | ||
|
|
ad344db2bf | ||
|
|
3626cd1a6d | ||
|
|
fe4e2d97d0 | ||
|
|
e712477d2b | ||
|
|
4419c434a1 | ||
|
|
687353a819 | ||
|
|
e4e277219e | ||
|
|
a75c46351f | ||
|
|
65a34d48b4 | ||
|
|
0e7095fee6 | ||
|
|
adac1b1f99 | ||
|
|
29ada9b681 | ||
|
|
92a2feba1e | ||
|
|
ba7e8ca6f5 | ||
|
|
f408f60631 | ||
| 38a6d6b0fc | |||
| b33d0eb850 | |||
|
|
4bcf568ed4 | ||
|
|
ddb1ec4df8 | ||
| d650b6c066 | |||
|
|
e63eaadc33 | ||
|
|
d4a25e34d8 |
@@ -161,3 +161,147 @@ jobs:
|
||||
# without first re-evaluating ADR-011.
|
||||
if: always()
|
||||
run: rm -f .env.staging
|
||||
|
||||
npm-audit:
|
||||
# Independent parallel job — a deploy failure cannot mask the audit signal
|
||||
# and a clean audit cannot hide a broken deploy. Intentionally no `needs:`.
|
||||
#
|
||||
# Scans dev deps too (no --omit=dev), which is deliberately broader than the
|
||||
# PR gate (ci.yml §Security audit) that uses --omit=dev. A nightly broader
|
||||
# result is NOT a PR gate failure — it catches dev-tooling advisories (esbuild,
|
||||
# Vite, etc.) early. See docs/infrastructure/ci-gitea.md §Nightly audit vs PR gate.
|
||||
#
|
||||
# Required Gitea secrets:
|
||||
# NIGHTLY_AUDIT_TOKEN — PAT with issues scope only. An issues-only token
|
||||
# means a leak via logs/process-args cannot push
|
||||
# branches, open PRs, or read repo contents (ADR-041).
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Assert jq is available
|
||||
run: which jq || sudo apt-get install -y jq
|
||||
|
||||
- name: Run npm audit and file tracking issue on findings
|
||||
# Never run under set -x — NIGHTLY_AUDIT_TOKEN in env would leak to logs.
|
||||
env:
|
||||
NIGHTLY_AUDIT_TOKEN: ${{ secrets.NIGHTLY_AUDIT_TOKEN }}
|
||||
run: |
|
||||
MARKER="Nightly npm audit: high-severity advisory"
|
||||
GITEA_URL="${{ github.server_url }}"
|
||||
REPO="${{ github.repository }}"
|
||||
RUN_URL="${GITEA_URL}/${REPO}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
# --- Self-test (mirrors ci.yml §Assert pattern) ---
|
||||
# Tests the exact jq test() call used in the dedupe step, before any
|
||||
# API call, so a broken matcher fails loudly early rather than silently
|
||||
# opening duplicate issues. Proves the regex only — create-vs-update
|
||||
# decision is exercised by the workflow_dispatch AC.
|
||||
echo "{\"title\": \"${MARKER}\"}" \
|
||||
| jq -e --arg m "$MARKER" '.title | test($m; "i")' > /dev/null \
|
||||
|| { echo "FAIL: self-test — jq test() missed tracking issue title"; exit 1; }
|
||||
echo '{"title": "fix(deps): update dependency esbuild (CVE-2025-12345)"}' \
|
||||
| jq -e --arg m "$MARKER" '.title | test($m; "i") | not' > /dev/null \
|
||||
|| { echo "FAIL: self-test — jq test() incorrectly matched unrelated title"; exit 1; }
|
||||
echo "Self-test passed."
|
||||
|
||||
# --- Run audit ---
|
||||
# No npm ci — audit reads only the lockfile (no network, no install).
|
||||
set +e
|
||||
(cd frontend && npm audit --audit-level=high --json > /tmp/audit.json)
|
||||
AUDIT_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ "$AUDIT_EXIT" -ne 0 ]; then
|
||||
# --- Build issue body with jq (never string-concat advisory text) ---
|
||||
# Advisory overview/title text is registry-controlled; string-concat
|
||||
# would be an injection/escaping vector into the API body. Truncate
|
||||
# raw excerpt to 500 chars so a pathological overview can't produce
|
||||
# a multi-MB PATCH body.
|
||||
ISSUE_BODY=$(jq -r \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'
|
||||
(.vulnerabilities // {}) as $vulns |
|
||||
($vulns | to_entries |
|
||||
map(select(.value.severity == "high" or .value.severity == "critical")) |
|
||||
map("- **" + .key + "** (" + .value.severity + ")") |
|
||||
if length > 0 then join("\n") else "_See raw output for details._" end) as $pkg_list |
|
||||
"## npm audit: high/critical advisories\n\n" + $pkg_list +
|
||||
"\n\n**Run:** " + $run_url +
|
||||
"\n\n<details><summary>Raw audit excerpt (first 500 chars)</summary>\n\n```\n" +
|
||||
(tostring | .[0:500]) +
|
||||
"\n```\n\n</details>"
|
||||
' /tmp/audit.json)
|
||||
|
||||
# --- Dedupe: fetch open security issues, match by title marker ---
|
||||
# Renovate vuln PRs also carry the "security" label, so >1 open
|
||||
# "security" issue WILL occur. Title-match (not just label) ensures
|
||||
# we deduplicate only our own tracking issue.
|
||||
OPEN_ISSUES=$(curl -sf \
|
||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues?state=open&type=issues&labels=security&limit=50")
|
||||
|
||||
MATCHED=$(echo "$OPEN_ISSUES" | jq \
|
||||
--arg m "$MARKER" \
|
||||
'[.[] | select(.title | test($m; "i"))] | sort_by(.created_at)')
|
||||
MATCH_COUNT=$(echo "$MATCHED" | jq 'length')
|
||||
|
||||
if [ "$MATCH_COUNT" -gt 0 ]; then
|
||||
# Patch the oldest matched issue (append run URL to body).
|
||||
ISSUE_NUMBER=$(echo "$MATCHED" | jq -r '.[0].number')
|
||||
EXISTING_BODY=$(echo "$MATCHED" | jq -r '.[0].body')
|
||||
NEW_BODY=$(jq -n \
|
||||
--arg existing "$EXISTING_BODY" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'$existing + "\n\n---\n\nUpdated by run: " + $run_url')
|
||||
PAYLOAD=$(jq -n --arg body "$NEW_BODY" '{"body": $body}')
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${ISSUE_NUMBER}" > /dev/null
|
||||
echo "Updated tracking issue #${ISSUE_NUMBER}"
|
||||
else
|
||||
# Closed prior issue that recurs → new issue (not reopened).
|
||||
# A re-opened issue would obscure when the advisory was re-discovered.
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "$MARKER" \
|
||||
--arg body "$ISSUE_BODY" \
|
||||
'{"title": $title, "body": $body}')
|
||||
CREATED=$(curl -sf -X POST \
|
||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues")
|
||||
NEW_NUMBER=$(echo "$CREATED" | jq -r '.number')
|
||||
echo "Opened new tracking issue #${NEW_NUMBER}"
|
||||
|
||||
# Labels are ignored on issue create in Gitea — add in a follow-up call.
|
||||
LABEL_IDS=$(curl -sf \
|
||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/labels?limit=50" \
|
||||
| jq '[.[] | select(.name == "security" or .name == "devops" or .name == "P1-high") | .id]')
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token $NIGHTLY_AUDIT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"labels\": $LABEL_IDS}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${NEW_NUMBER}/labels" > /dev/null
|
||||
fi
|
||||
|
||||
exit "$AUDIT_EXIT"
|
||||
|
||||
else
|
||||
# --- Heartbeat: proves the job ran and found nothing ---
|
||||
# "No issue created" is only meaningful evidence when paired with a
|
||||
# visible positive signal. Without this, a never-ran job is
|
||||
# indistinguishable from a clean run.
|
||||
#
|
||||
# $GITHUB_STEP_SUMMARY availability is unproven on this runner
|
||||
# (act_runner populates it, but this is the first run to verify it).
|
||||
# Guard before use so an unset variable does not fail the clean-path.
|
||||
MSG="✅ npm audit clean $(date -u)"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
echo "$MSG" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "$MSG"
|
||||
fi
|
||||
|
||||
44
.gitea/workflows/renovate.yml
Normal file
44
.gitea/workflows/renovate.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Renovate
|
||||
|
||||
# Runs Renovate daily to surface newly-published advisories via OSV.dev
|
||||
# (osvVulnerabilityAlerts) and open routine update PRs on a weekly batch
|
||||
# schedule (see renovate.json §schedule). Security/vulnerability PRs are
|
||||
# raised immediately regardless of the weekly schedule window.
|
||||
#
|
||||
# Required Gitea secrets (see docs/adr/041-renovate-runner-setup.md):
|
||||
# RENOVATE_TOKEN — PAT with scopes: contents + pull_request + issues
|
||||
# Belongs to a dedicated bot account. Branch protection
|
||||
# on main must forbid this bot pushing directly.
|
||||
#
|
||||
# Platform config is injected via env vars below; the renovate.json in the
|
||||
# repo root carries only dependency rules (no platform/endpoint/repos).
|
||||
#
|
||||
# Digest pin: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd
|
||||
# corresponds to release v46.1.15. Update by bumping both the digest and the
|
||||
# renovate-version when Renovate publishes a new release. Renovate itself
|
||||
# will open a PR to bump this digest once it runs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *" # daily at 03:00 UTC — cuts OSV-alert latency to ≤1 day
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Renovate
|
||||
# Pinned by digest — this action holds contents+pull_request+issues
|
||||
# scopes; an unpinned tag is a supply-chain risk (see ADR-041).
|
||||
uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
|
||||
with:
|
||||
configurationFile: renovate.json
|
||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||
renovate-version: "46.1.15"
|
||||
env:
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://git.raddatz.cloud
|
||||
RENOVATE_REPOSITORIES: '["marcel/familienarchiv"]'
|
||||
LOG_LEVEL: info
|
||||
28
CLAUDE.md
28
CLAUDE.md
@@ -86,15 +86,16 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
||||
├── filestorage/ FileService (S3/MinIO)
|
||||
├── geschichte/ Geschichte (story) domain
|
||||
├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||
│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
|
||||
├── notification/ Notification domain + SseEmitterRegistry
|
||||
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
||||
├── person/ Person domain
|
||||
│ └── relationship/ PersonRelationship sub-domain
|
||||
├── search/ NL search domain — NlSearchController, NlQueryParserService, RestClientOllamaClient, NlSearchRateLimiter
|
||||
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||
├── tag/ Tag domain
|
||||
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
|
||||
└── user/ User domain — AppUser, UserGroup, UserService
|
||||
```
|
||||
|
||||
@@ -106,13 +107,16 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
|
||||
### Domain Model
|
||||
|
||||
| Entity | Table | Key relationships |
|
||||
| ----------- | ------------- | ------------------------------------------------------------------------------------- |
|
||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||
| Entity | Table | Key relationships |
|
||||
| ------------- | --------------- | --------------------------------------------------------------------------------------- |
|
||||
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
|
||||
| `Person` | `persons` | Referenced by documents as sender/receiver |
|
||||
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
|
||||
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
|
||||
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
|
||||
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
|
||||
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
|
||||
| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail |
|
||||
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
@@ -153,7 +157,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
|
||||
|
||||
### DTOs
|
||||
|
||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
|
||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs) — **except the geschichte domain**, where every response is a view (`GeschichteView`/`GeschichteSummary`/`JourneyItemView`) assembled inside the service transaction and entities never cross the controller boundary. See [ADR-036](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — lazy collections + `open-in-view: false` make serialized entities a 500 waiting to happen.
|
||||
|
||||
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
||||
|
||||
@@ -161,7 +165,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
|
||||
|
||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||
|
||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `SMART_SEARCH_UNAVAILABLE` (HTTP 503 — Ollama inference service offline or timed out); `SMART_SEARCH_RATE_LIMITED` (HTTP 429 — user exceeded 5 NL search requests per minute).
|
||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
|
||||
|
||||
### Security / Permissions
|
||||
|
||||
@@ -269,7 +273,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
|
||||
|
||||
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
|
||||
|
||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `SMART_SEARCH_UNAVAILABLE` (HTTP 503 — Ollama inference service offline or timed out); `SMART_SEARCH_RATE_LIMITED` (HTTP 429 — user exceeded 5 NL search requests per minute).
|
||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
|
||||
├── filestorage/ # FileService (S3/MinIO)
|
||||
├── geschichte/ # Geschichte (story) domain
|
||||
├── geschichte/ # Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||
│ └── journeyitem/ # JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
|
||||
├── notification/ # Notification domain + SseEmitterRegistry
|
||||
├── ocr/ # OCR domain — OcrService, OcrBatchService, training
|
||||
@@ -41,6 +42,7 @@ src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── relationship/ # PersonRelationship sub-domain
|
||||
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||
├── tag/ # Tag domain — Tag, TagService, TagController
|
||||
├── timeline/ # Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
|
||||
└── user/ # User domain — AppUser, UserGroup, UserService
|
||||
```
|
||||
|
||||
@@ -66,6 +68,7 @@ For per-domain ownership and public surface, see each domain's `README.md`.
|
||||
| `Comment` | `document_comments` | Threaded comments with mentions |
|
||||
| `Notification` | `notifications` | User notification feed |
|
||||
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
|
||||
| `TimelineEvent` | `timeline_events` | Curated Zeitstrahl event; ManyToMany persons + documents (join FKs ON DELETE CASCADE); `@Version` + NOT NULL createdBy/updatedBy |
|
||||
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
|
||||
@@ -50,10 +50,30 @@ public enum AuditKind {
|
||||
ADMIN_FORCE_LOGOUT,
|
||||
|
||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||
LOGIN_RATE_LIMITED;
|
||||
LOGIN_RATE_LIMITED,
|
||||
|
||||
// --- Documents ---
|
||||
|
||||
/** Payload: none — the deleted document's id is carried in the documentId column */
|
||||
DOCUMENT_DELETED,
|
||||
|
||||
// --- Reading Journeys (Lesereisen) ---
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */
|
||||
JOURNEY_ITEM_ADDED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_REMOVED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_NOTE_UPDATED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */
|
||||
JOURNEY_ITEMS_REORDERED;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
|
||||
JOURNEY_ITEMS_REORDERED
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,8 +168,8 @@ public class DocumentController {
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
|
||||
documentService.deleteDocument(id);
|
||||
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id, Authentication authentication) {
|
||||
documentService.deleteDocument(id, requireUserId(authentication));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Published by DocumentService.deleteDocument inside its @Transactional boundary,
|
||||
* before documentRepository.deleteById fires. Listeners run synchronously in the
|
||||
* publisher's thread and transaction via plain @EventListener — this is load-bearing:
|
||||
* see ADR-038.
|
||||
*/
|
||||
public record DocumentDeletingEvent(UUID documentId) {}
|
||||
@@ -36,6 +36,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
@EntityGraph("Document.list")
|
||||
Page<Document> findAll(Pageable pageable);
|
||||
|
||||
// Loader for the relevance fast path: list-item enrichment reads tags after the
|
||||
// repository call returns, so the fetch shape must match the spec-based findAll
|
||||
// overloads above. Plain findAllById carries no entity graph and must not feed
|
||||
// enrichItems — see DocumentService.relevanceSortedPageFromSql.
|
||||
@EntityGraph("Document.list")
|
||||
List<Document> findByIdIn(Collection<UUID> ids);
|
||||
|
||||
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.raddatz.familienarchiv.ocr.TrainingLabel;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -80,6 +81,7 @@ public class DocumentService {
|
||||
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
private final AuditLogQueryService auditLogQueryService;
|
||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public record StoreResult(Document document, boolean isNew) {}
|
||||
|
||||
@@ -851,14 +853,14 @@ public class DocumentService {
|
||||
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
||||
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
|
||||
// Preserve ts_rank order from SQL across the JPA findAllById call.
|
||||
// Preserve ts_rank order from SQL across the JPA findByIdIn call.
|
||||
Map<UUID, Integer> rankMap = new HashMap<>();
|
||||
List<UUID> pageIds = new ArrayList<>();
|
||||
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
||||
rankMap.put(ftsPage.hits().get(i).id(), i);
|
||||
pageIds.add(ftsPage.hits().get(i).id());
|
||||
}
|
||||
List<Document> docs = documentRepository.findAllById(pageIds).stream()
|
||||
List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
|
||||
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
||||
.toList();
|
||||
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
||||
@@ -1006,6 +1008,28 @@ public class DocumentService {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight summary lookup for internal use (e.g. journey item append validation).
|
||||
*
|
||||
* <p><strong>Security contract — read before calling:</strong>
|
||||
* <ol>
|
||||
* <li>This method intentionally bypasses per-document scope checks and
|
||||
* tag-colour resolution. It must only be invoked after
|
||||
* {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
|
||||
* the controller layer, guaranteeing the caller is an authenticated
|
||||
* author.</li>
|
||||
* <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
|
||||
* JOURNEY-type check that fires before this call — so the method is never
|
||||
* reached for STORY-type Geschichten.</li>
|
||||
* </ol>
|
||||
* Under the current single-tenant model every authenticated author shares the
|
||||
* same document scope, so skipping per-document scope checks is safe.
|
||||
*/
|
||||
public Document findSummaryByIdInternal(UUID id) {
|
||||
return documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a document for the detail view, additionally flagging whether it has any
|
||||
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
|
||||
@@ -1075,11 +1099,13 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteDocument(UUID id) {
|
||||
public void deleteDocument(UUID id, UUID actorId) {
|
||||
if (!documentRepository.existsById(id)) {
|
||||
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
||||
}
|
||||
eventPublisher.publishEvent(new DocumentDeletingEvent(id));
|
||||
documentRepository.deleteById(id);
|
||||
auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, actorId, id, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -15,6 +15,10 @@ public enum ErrorCode {
|
||||
ALIAS_NOT_FOUND,
|
||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||
INVALID_PERSON_TYPE,
|
||||
/** A person's birth date is after their death date. 400 */
|
||||
BIRTH_AFTER_DEATH,
|
||||
/** A life date and its precision are incoherent: date present with UNKNOWN precision, or precision set without a date. 400 */
|
||||
INVALID_DATE_PRECISION,
|
||||
// --- Documents ---
|
||||
/** A document with the given ID does not exist. 404 */
|
||||
DOCUMENT_NOT_FOUND,
|
||||
@@ -122,6 +126,22 @@ public enum ErrorCode {
|
||||
// --- Geschichten (Stories) ---
|
||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||
GESCHICHTE_NOT_FOUND,
|
||||
/** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */
|
||||
JOURNEY_ITEM_NOT_FOUND,
|
||||
/** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */
|
||||
JOURNEY_ITEM_POSITION_CONFLICT,
|
||||
/** The journey already has the maximum allowed number of items (100). 400 */
|
||||
JOURNEY_AT_CAPACITY,
|
||||
/** The document is already present in this journey — duplicate items are not allowed. 409 */
|
||||
JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
/** The type of an existing Geschichte cannot be changed via PATCH. 409 */
|
||||
GESCHICHTE_TYPE_IMMUTABLE,
|
||||
/** A journey-item note exceeds the maximum length (2000 characters). 400 */
|
||||
JOURNEY_NOTE_TOO_LONG,
|
||||
/** A Geschichte title exceeds the maximum length (255 characters — the DB column bound). 400 */
|
||||
GESCHICHTE_TITLE_TOO_LONG,
|
||||
/** A JOURNEY intro (body) exceeds the maximum length (4000 characters). 400 */
|
||||
GESCHICHTE_INTRO_TOO_LONG,
|
||||
|
||||
// --- Tags ---
|
||||
/** A tag with the given ID does not exist. 404 */
|
||||
@@ -135,12 +155,6 @@ public enum ErrorCode {
|
||||
/** The merge target is a descendant of the source tag. 400 */
|
||||
TAG_MERGE_INVALID_TARGET,
|
||||
|
||||
// --- NL Search ---
|
||||
/** Ollama is unreachable or timed out. 503 */
|
||||
SMART_SEARCH_UNAVAILABLE,
|
||||
/** NL search rate limit exceeded (5 requests per user per minute). 429 */
|
||||
SMART_SEARCH_RATE_LIMITED,
|
||||
|
||||
// --- Generic ---
|
||||
/** Request validation failed (missing or malformed fields). 400 */
|
||||
VALIDATION_ERROR,
|
||||
|
||||
@@ -78,7 +78,14 @@ public class GlobalExceptionHandler {
|
||||
// Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which
|
||||
// constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the
|
||||
// offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault.
|
||||
log.warn("Rejected a request that violated a database integrity constraint: {}", constraintNameOf(ex));
|
||||
String constraint = constraintNameOf(ex);
|
||||
log.warn("Rejected a request that violated a database integrity constraint: {}", constraint);
|
||||
if ("uq_journey_items_geschichte_position".equals(constraint)) {
|
||||
// DEFERRABLE INITIALLY DEFERRED — fires at commit when concurrent appends/reorders collide
|
||||
return ResponseEntity.status(409)
|
||||
.body(new ErrorResponse(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT,
|
||||
"A position conflict was detected — another request modified this journey simultaneously"));
|
||||
}
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -40,6 +42,12 @@ public class Geschichte {
|
||||
@Builder.Default
|
||||
private GeschichteStatus status = GeschichteStatus.DRAFT;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private GeschichteType type = GeschichteType.STORY;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "author_id")
|
||||
private AppUser author;
|
||||
@@ -51,12 +59,18 @@ public class Geschichte {
|
||||
@Builder.Default
|
||||
private Set<Person> persons = new HashSet<>();
|
||||
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
@JoinTable(name = "geschichten_documents",
|
||||
joinColumns = @JoinColumn(name = "geschichte_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
||||
// LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.md. open-in-view is FALSE
|
||||
// (application.yaml), so this collection is DEAD at Jackson serialization time unless
|
||||
// explicitly initialized inside the service transaction. getById() is
|
||||
// @Transactional(readOnly=true) AND calls getItems().size() to force-init before return.
|
||||
// list() must NOT serialize items at all — it returns a GeschichteSummary projection.
|
||||
// This is the first List ("bag") collection on Geschichte — adding a second EAGER/
|
||||
// fetch-joined List here will throw MultipleBagFetchException at boot.
|
||||
@OneToMany(mappedBy = "geschichte", cascade = CascadeType.ALL, orphanRemoval = true,
|
||||
fetch = FetchType.LAZY)
|
||||
@OrderBy("position ASC")
|
||||
@Builder.Default
|
||||
private Set<Document> documents = new HashSet<>();
|
||||
private List<JourneyItem> items = new ArrayList<>();
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(updatable = false)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -14,6 +17,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -28,12 +32,17 @@ import java.util.UUID;
|
||||
public class GeschichteController {
|
||||
|
||||
private final GeschichteService geschichteService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
@GetMapping
|
||||
public List<Geschichte> list(
|
||||
public List<GeschichteSummary> list(
|
||||
@Parameter(description = "Filter by status. Callers without BLOG_WRITE always receive PUBLISHED results regardless of the value passed. Callers with BLOG_WRITE requesting DRAFT receive only their own unpublished stories.")
|
||||
@RequestParam(required = false) GeschichteStatus status,
|
||||
@Parameter(description = "AND-filter: story must include all supplied person IDs.")
|
||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
||||
@Parameter(description = "Filter to stories containing this document.")
|
||||
@RequestParam(required = false) UUID documentId,
|
||||
@Parameter(description = "Maximum results to return. Values ≤ 0 default to 50. Clamped at 200.")
|
||||
@RequestParam(required = false, defaultValue = "50") int limit) {
|
||||
return geschichteService.list(
|
||||
status,
|
||||
@@ -43,20 +52,20 @@ public class GeschichteController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Geschichte getById(@PathVariable UUID id) {
|
||||
return geschichteService.getById(id);
|
||||
public GeschichteView getById(@PathVariable UUID id) {
|
||||
return geschichteService.getView(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||
Geschichte created = geschichteService.create(dto);
|
||||
public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||
GeschichteView created = geschichteService.create(dto);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||
public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||
return geschichteService.update(id, dto);
|
||||
}
|
||||
|
||||
@@ -66,4 +75,45 @@ public class GeschichteController {
|
||||
geschichteService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ─── JourneyItem CRUD ────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/{id}/items")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<JourneyItemView> appendItem(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyItemCreateDTO dto) {
|
||||
JourneyItemView view = journeyItemService.append(id, dto);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(view);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public JourneyItemView updateItemNote(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId,
|
||||
@RequestBody JourneyItemUpdateDTO dto) {
|
||||
return journeyItemService.updateNote(id, itemId, dto);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<Void> deleteItem(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId) {
|
||||
journeyItemService.delete(id, itemId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/items/reorder")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
@Operation(
|
||||
summary = "Reorder journey items",
|
||||
description = "itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request."
|
||||
)
|
||||
public List<JourneyItemView> reorderItems(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyReorderDTO dto) {
|
||||
return journeyItemService.reorder(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Thin read-only service owning {@link GeschichteRepository}.
|
||||
* Exists so that {@code JourneyItemService} can check Geschichte existence
|
||||
* and load Geschichte instances without holding a direct reference to the
|
||||
* Geschichte repository (cross-domain repository access is not allowed per
|
||||
* layering rules).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GeschichteQueryService {
|
||||
|
||||
private final GeschichteRepository geschichteRepository;
|
||||
|
||||
public boolean existsById(UUID id) {
|
||||
return geschichteRepository.existsById(id);
|
||||
}
|
||||
|
||||
public Optional<Geschichte> findById(UUID id) {
|
||||
return geschichteRepository.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,47 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
||||
|
||||
/**
|
||||
* Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false).
|
||||
*
|
||||
* <p>Status clamp: callers must pass the effective status (PUBLISHED for readers,
|
||||
* raw status for BLOG_WRITE users). authorId restricts to own drafts when effective=DRAFT.
|
||||
*
|
||||
* <p>Person filter: personCount=0 disables the filter. When personCount>0, the story must
|
||||
* be associated with ALL person ids in personIds (AND-semantics via counting subquery).
|
||||
* Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
|
||||
g.author AS author, g.publishedAt AS publishedAt, g.updatedAt AS updatedAt, g.body AS body
|
||||
FROM Geschichte g
|
||||
WHERE g.status = :effectiveStatus
|
||||
AND (:authorId IS NULL OR g.author.id = :authorId)
|
||||
AND (:personCount = 0 OR
|
||||
(SELECT COUNT(DISTINCT p.id)
|
||||
FROM Geschichte g2 JOIN g2.persons p
|
||||
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
|
||||
AND (:documentId IS NULL OR
|
||||
EXISTS (SELECT 1 FROM JourneyItem ji
|
||||
WHERE ji.geschichte = g AND ji.document.id = :documentId))
|
||||
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
|
||||
""")
|
||||
List<GeschichteSummary> findSummaries(
|
||||
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
|
||||
@Param("authorId") UUID authorId,
|
||||
@Param("personIds") Collection<UUID> personIds,
|
||||
@Param("personCount") long personCount,
|
||||
@Param("documentId") UUID documentId);
|
||||
}
|
||||
|
||||
@@ -4,28 +4,23 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.owasp.html.HtmlPolicyBuilder;
|
||||
import org.owasp.html.PolicyFactory;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -41,6 +36,7 @@ public class GeschichteService {
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final UserService userService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
/**
|
||||
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
||||
@@ -54,12 +50,26 @@ public class GeschichteService {
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
private static final int MAX_LIMIT = 200;
|
||||
|
||||
/** Sentinel used when {@code personIds} is empty to avoid invalid empty IN() SQL. */
|
||||
private static final UUID NIL_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
|
||||
|
||||
// Matches the geschichten.title VARCHAR(255) column (V58) — the service check
|
||||
// turns what would be a DB-level 500 into a friendly 400.
|
||||
static final int MAX_TITLE_LENGTH = 255;
|
||||
// JOURNEY intros travel the verbatim (unsanitized) write path, so they get the
|
||||
// same three-layer bound as journey notes: frontend maxlength, this check, and
|
||||
// the V75 CHECK constraint. STORY bodies are sanitized Tiptap HTML and stay
|
||||
// unbounded on purpose.
|
||||
static final int MAX_INTRO_LENGTH = 4000;
|
||||
|
||||
// ─── Read API ────────────────────────────────────────────────────────────
|
||||
|
||||
public long countPublished() {
|
||||
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
||||
}
|
||||
|
||||
// readOnly = true: lazy collections resolve within the same tx when called from getView()
|
||||
@Transactional(readOnly = true)
|
||||
public Geschichte getById(UUID id) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
@@ -72,24 +82,62 @@ public class GeschichteService {
|
||||
return g;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public GeschichteView getView(UUID id) {
|
||||
Geschichte g = getById(id);
|
||||
List<JourneyItemView> items = journeyItemService.getItems(id);
|
||||
return toView(g, items);
|
||||
}
|
||||
|
||||
GeschichteView toView(Geschichte g, List<JourneyItemView> items) {
|
||||
AppUser author = g.getAuthor();
|
||||
GeschichteView.AuthorView authorView = null;
|
||||
if (author != null) {
|
||||
String displayName = PersonNameFormatter.join(author.getFirstName(), author.getLastName());
|
||||
if (displayName.isBlank()) displayName = "[Unbekannt]";
|
||||
authorView = new GeschichteView.AuthorView(author.getId(), displayName);
|
||||
}
|
||||
Set<GeschichteView.PersonView> personViews = new HashSet<>();
|
||||
for (Person p : g.getPersons()) {
|
||||
personViews.add(new GeschichteView.PersonView(p.getId(), p.getFirstName(), p.getLastName()));
|
||||
}
|
||||
return new GeschichteView(
|
||||
g.getId(), g.getTitle(), g.getBody(),
|
||||
g.getStatus(), g.getType(),
|
||||
authorView, personViews,
|
||||
items,
|
||||
g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
|
||||
* must be associated with every person id supplied. An empty or null list applies no
|
||||
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
|
||||
*
|
||||
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
|
||||
* LazyInitializationException on the non-transactional list path.
|
||||
*
|
||||
* <p>Security: {@code null} status always resolves to PUBLISHED — even for blog writers.
|
||||
* Only an explicit {@code DRAFT} request scopes the query to the caller's own drafts.
|
||||
* This prevents CWE-639: a blog writer passing {@code null} must not see all authors' drafts.
|
||||
*/
|
||||
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||
boolean isDraftRequest = currentUserHasBlogWrite() && status == GeschichteStatus.DRAFT;
|
||||
GeschichteStatus effective = isDraftRequest ? GeschichteStatus.DRAFT : GeschichteStatus.PUBLISHED;
|
||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||
|
||||
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
|
||||
Specification<Geschichte> spec = Specification.allOf(
|
||||
GeschichteSpecifications.hasStatus(effective),
|
||||
GeschichteSpecifications.hasAuthor(authorId),
|
||||
GeschichteSpecifications.hasAllPersons(personIds),
|
||||
GeschichteSpecifications.hasDocument(documentId),
|
||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
||||
);
|
||||
return geschichteRepository.findAll(spec, Sort.unsorted())
|
||||
UUID authorId = isDraftRequest ? currentUser().getId() : null;
|
||||
|
||||
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
|
||||
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
|
||||
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
|
||||
? List.of(NIL_UUID)
|
||||
: personIds;
|
||||
long personCount = (personIds == null) ? 0 : personIds.size();
|
||||
|
||||
return geschichteRepository
|
||||
.findSummaries(effective, authorId, safePersonIds, personCount, documentId)
|
||||
.stream()
|
||||
.limit(safeLimit)
|
||||
.toList();
|
||||
@@ -97,46 +145,57 @@ public class GeschichteService {
|
||||
|
||||
// ─── Write API ───────────────────────────────────────────────────────────
|
||||
|
||||
// Write methods return GeschichteView, never the entity: Jackson serializes after
|
||||
// the transaction closed, where the lazy items collection is a dead proxy.
|
||||
// The view is assembled in-transaction, so no force-init tricks are needed.
|
||||
|
||||
@Transactional
|
||||
public Geschichte create(GeschichteUpdateDTO dto) {
|
||||
public GeschichteView create(GeschichteUpdateDTO dto) {
|
||||
requireTitle(dto.getTitle());
|
||||
GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
|
||||
Geschichte g = Geschichte.builder()
|
||||
.title(dto.getTitle().trim())
|
||||
.body(sanitize(dto.getBody()))
|
||||
.body(bodyForType(type, dto.getBody()))
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(type)
|
||||
.author(currentUser())
|
||||
.persons(resolvePersons(dto.getPersonIds()))
|
||||
.documents(resolveDocuments(dto.getDocumentIds()))
|
||||
.build();
|
||||
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
||||
g.setStatus(GeschichteStatus.PUBLISHED);
|
||||
g.setPublishedAt(LocalDateTime.now());
|
||||
}
|
||||
return geschichteRepository.save(g);
|
||||
Geschichte saved = geschichteRepository.save(g);
|
||||
// A freshly created Geschichte has no items by construction — items are only
|
||||
// addable via the separate /items endpoints. Revisit if a create DTO ever
|
||||
// accepts initial items.
|
||||
return toView(saved, List.of());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
|
||||
public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
||||
if (dto.getType() != null && dto.getType() != g.getType()) {
|
||||
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE,
|
||||
"The type of a Geschichte cannot be changed after creation");
|
||||
}
|
||||
if (dto.getTitle() != null) {
|
||||
requireTitle(dto.getTitle());
|
||||
g.setTitle(dto.getTitle().trim());
|
||||
}
|
||||
if (dto.getBody() != null) {
|
||||
g.setBody(sanitize(dto.getBody()));
|
||||
g.setBody(bodyForType(g.getType(), dto.getBody()));
|
||||
}
|
||||
if (dto.getPersonIds() != null) {
|
||||
g.setPersons(resolvePersons(dto.getPersonIds()));
|
||||
}
|
||||
if (dto.getDocumentIds() != null) {
|
||||
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
|
||||
}
|
||||
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
||||
applyStatusTransition(g, dto.getStatus());
|
||||
}
|
||||
return geschichteRepository.save(g);
|
||||
Geschichte saved = geschichteRepository.save(g);
|
||||
return toView(saved, journeyItemService.getItems(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -164,6 +223,27 @@ public class GeschichteService {
|
||||
throw DomainException.badRequest(
|
||||
ErrorCode.VALIDATION_ERROR, "Title is required");
|
||||
}
|
||||
if (title.trim().length() > MAX_TITLE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.GESCHICHTE_TITLE_TOO_LONG,
|
||||
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer.
|
||||
* JOURNEY intros are plain text: the reader renders them via Svelte text
|
||||
* interpolation (never {@code {@html}}), so entity-encoding them here would
|
||||
* corrupt content ("&" → "&") and re-encode on every editor round-trip.
|
||||
*/
|
||||
private String bodyForType(GeschichteType type, String body) {
|
||||
if (type != GeschichteType.JOURNEY) {
|
||||
return sanitize(body);
|
||||
}
|
||||
if (body != null && body.length() > MAX_INTRO_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.GESCHICHTE_INTRO_TOO_LONG,
|
||||
"Intro exceeds maximum length of " + MAX_INTRO_LENGTH + " characters");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private String sanitize(String body) {
|
||||
@@ -176,15 +256,6 @@ public class GeschichteService {
|
||||
return new LinkedHashSet<>(personService.getAllById(ids));
|
||||
}
|
||||
|
||||
private Set<Document> resolveDocuments(List<UUID> ids) {
|
||||
if (ids == null || ids.isEmpty()) return new HashSet<>();
|
||||
Set<Document> out = new LinkedHashSet<>();
|
||||
for (UUID id : ids) {
|
||||
out.add(documentService.getDocumentById(id));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private AppUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
|
||||
@@ -6,9 +6,6 @@ import jakarta.persistence.criteria.Join;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
import jakarta.persistence.criteria.Subquery;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
|
||||
@@ -48,12 +45,7 @@ public final class GeschichteSpecifications {
|
||||
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
|
||||
}
|
||||
|
||||
public static Specification<Geschichte> hasDocument(UUID documentId) {
|
||||
return (root, query, cb) -> {
|
||||
if (documentId == null) return null;
|
||||
return cb.exists(documentSubquery(root, query, cb, documentId));
|
||||
};
|
||||
}
|
||||
// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands
|
||||
|
||||
/**
|
||||
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
|
||||
@@ -84,14 +76,4 @@ public final class GeschichteSpecifications {
|
||||
return sub;
|
||||
}
|
||||
|
||||
private static Subquery<UUID> documentSubquery(
|
||||
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
|
||||
Subquery<UUID> sub = query.subquery(UUID.class);
|
||||
Root<Geschichte> subRoot = sub.from(Geschichte.class);
|
||||
Join<Geschichte, Document> documents = subRoot.join("documents");
|
||||
sub.select(subRoot.get("id"))
|
||||
.where(cb.equal(subRoot.get("id"), root.get("id")),
|
||||
cb.equal(documents.get("id"), documentId));
|
||||
return sub;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* List-projection for the /api/geschichten grid. Never carries items — avoids
|
||||
* LazyInitializationException (open-in-view: false) and prevents Cartesian joins.
|
||||
* Mirrors the PersonSummaryDTO precedent.
|
||||
*
|
||||
* <p>Field set: exactly what the live grid card renders (title, author byline, body excerpt,
|
||||
* publishedAt, status, type). Does NOT carry items or persons.
|
||||
*/
|
||||
public interface GeschichteSummary {
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
UUID getId();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String getTitle();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
GeschichteStatus getStatus();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
GeschichteType getType();
|
||||
|
||||
/** Nested closed projection — exposes only the fields the grid card needs. */
|
||||
AuthorSummary getAuthor();
|
||||
|
||||
LocalDateTime getPublishedAt();
|
||||
|
||||
/** Always set (@UpdateTimestamp) — drives "bearbeitet vor X" on dashboard cards. */
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
LocalDateTime getUpdatedAt();
|
||||
|
||||
String getBody();
|
||||
|
||||
/** Author projection — names only; never email or group memberships (same rule as GeschichteView.AuthorView). */
|
||||
interface AuthorSummary {
|
||||
String getFirstName();
|
||||
String getLastName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
public enum GeschichteType {
|
||||
STORY,
|
||||
JOURNEY
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.Data;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -16,6 +15,6 @@ public class GeschichteUpdateDTO {
|
||||
private String title;
|
||||
private String body;
|
||||
private GeschichteStatus status;
|
||||
private GeschichteType type;
|
||||
private List<UUID> personIds;
|
||||
private List<UUID> documentIds;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Detail-view response for GET /api/geschichten/{id}. Assembled by
|
||||
* GeschichteService — never the raw entity (author AppUser graph must not leak).
|
||||
* items is always present (both STORY and JOURNEY); empty list for stories with no items.
|
||||
*/
|
||||
public record GeschichteView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
String body,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteStatus status,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteType type,
|
||||
AuthorView author,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<PersonView> persons,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<JourneyItemView> items,
|
||||
LocalDateTime publishedAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt
|
||||
) {
|
||||
/** Summarised author — exposes only id and displayName, never email or group memberships. */
|
||||
public record AuthorView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName
|
||||
) {}
|
||||
|
||||
/** Summarised person — exposes only id, firstName, and lastName. No admin-only fields. */
|
||||
public record PersonView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
String firstName,
|
||||
String lastName
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
/**
|
||||
* Utility for joining a person's first and last name into a display string.
|
||||
* Centralises the logic that was previously duplicated across GeschichteService
|
||||
* and JourneyItemService.
|
||||
*/
|
||||
public class PersonNameFormatter {
|
||||
|
||||
private PersonNameFormatter() {
|
||||
// utility class — no instances
|
||||
}
|
||||
|
||||
public static String join(String firstName, String lastName) {
|
||||
String first = firstName != null ? firstName.trim() : "";
|
||||
String last = lastName != null ? lastName.trim() : "";
|
||||
if (first.isEmpty() && last.isEmpty()) return "";
|
||||
if (first.isEmpty()) return last;
|
||||
if (last.isEmpty()) return first;
|
||||
return first + " " + last;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Lean read-model view of a Document for embedding in JourneyItemView.
|
||||
* Built by JourneyItemService.toSummary(Document) — never serialised from
|
||||
* a JPA entity to avoid LazyInitializationException and tag-color overhead.
|
||||
*/
|
||||
public record DocumentSummary(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
LocalDate documentDate,
|
||||
LocalDate documentDateEnd,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision datePrecision,
|
||||
String senderName,
|
||||
String receiverName,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Integer receiverCount
|
||||
) {}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "journey_items")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class JourneyItem {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "geschichte_id", nullable = false)
|
||||
@JsonIgnore
|
||||
private Geschichte geschichte;
|
||||
|
||||
// Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order
|
||||
// — the editor is responsible for keeping them distinct.
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int position;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "document_id")
|
||||
@JsonIgnore
|
||||
private Document document;
|
||||
|
||||
/**
|
||||
* Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output.
|
||||
*
|
||||
* <p>CWE-79 tripwire: stored verbatim; only Svelte {note} interpolation is auto-safe.</p>
|
||||
*/
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String note;
|
||||
|
||||
// JPA uses field access — this getter is not persisted. Jackson serializes it as documentId.
|
||||
// Exposing only the UUID prevents circular references and large nested payloads.
|
||||
public UUID getDocumentId() {
|
||||
return document != null ? document.getId() : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Input for POST /api/geschichten/{id}/items. Both fields optional; at least one must be present. */
|
||||
@Data
|
||||
public class JourneyItemCreateDTO {
|
||||
private UUID documentId;
|
||||
private String note;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.document.DocumentDeletingEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
class JourneyItemDocumentDeleteListener {
|
||||
|
||||
private final JourneyItemRepository journeyItemRepository;
|
||||
|
||||
/**
|
||||
* Plain @EventListener — runs synchronously in the publisher's thread and transaction.
|
||||
* Load-bearing choice: AFTER_COMMIT would fire after the FK ON DELETE SET NULL has
|
||||
* already 500'd; @Async would run outside the delete transaction (breaks AC-5 rollback).
|
||||
* See ADR-038. DocumentService cannot call JourneyItemService directly because
|
||||
* Spring Framework 7 prohibits the resulting constructor-injection cycle.
|
||||
*/
|
||||
@EventListener
|
||||
void onDocumentDeleting(DocumentDeletingEvent event) {
|
||||
int deleted = journeyItemRepository.deleteNoteLessByDocumentId(event.documentId());
|
||||
if (deleted > 0) {
|
||||
log.warn("Cascade-deleted {} note-less journey item(s) for document {}", deleted, event.documentId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID> {
|
||||
|
||||
/** Returns items ordered by position ASC for the read-model assembly path. */
|
||||
List<JourneyItem> findByGeschichteIdOrderByPosition(UUID geschichteId);
|
||||
|
||||
/** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */
|
||||
Optional<JourneyItem> findByIdAndGeschichteId(UUID id, UUID geschichteId);
|
||||
|
||||
/** Returns only the IDs — used for set-equality check in reorder. */
|
||||
@Query("SELECT i.id FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||
Set<UUID> findIdsByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||
|
||||
/** MAX position for computing the next append position; returns empty when journey has no items. */
|
||||
@Query("SELECT MAX(i.position) FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||
Optional<Integer> findMaxPositionByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||
|
||||
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
|
||||
long countByGeschichteId(UUID geschichteId);
|
||||
|
||||
/**
|
||||
* Dedup guard: true when the document is already linked to this journey.
|
||||
* Explicit JPQL, not a derived query: the transient {@code getDocumentId()}
|
||||
* getter on JourneyItem makes Spring Data resolve the derived path as a
|
||||
* direct {@code documentId} attribute, which Hibernate cannot map.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COUNT(i) > 0 FROM JourneyItem i
|
||||
WHERE i.geschichte.id = :geschichteId AND i.document.id = :documentId
|
||||
""")
|
||||
boolean existsByGeschichteIdAndDocumentId(
|
||||
@Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId);
|
||||
|
||||
/**
|
||||
* Deletes note-less items (note IS NULL or note = '') linked to the given document.
|
||||
* Used by JourneyItemDocumentDeleteListener before the document row is removed, so
|
||||
* the FK ON DELETE SET NULL never fires on rows that would violate chk_journey_item_not_empty.
|
||||
* Explicit JPQL — same trap as existsByGeschichteIdAndDocumentId: the transient
|
||||
* getDocumentId() getter makes Spring Data unable to resolve a derived query path.
|
||||
* clearAutomatically = true invalidates the L1 cache so AC-2's "note-carrying survives"
|
||||
* assertion never reads a stale entity. flushAutomatically = true makes the
|
||||
* flush-before-delete contract explicit rather than relying on Hibernate AUTO flush mode.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
|
||||
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
|
||||
|
||||
/**
|
||||
* Loads journey items with their linked Document in a single JOIN FETCH query,
|
||||
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
|
||||
* lazily for each item. Items without a document (note-only) are included via
|
||||
* LEFT JOIN. Ordered by position ASC.
|
||||
*/
|
||||
@Query("SELECT ji FROM JourneyItem ji LEFT JOIN FETCH ji.document WHERE ji.geschichte.id = :geschichteId ORDER BY ji.position ASC")
|
||||
List<JourneyItem> findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JourneyItemService {
|
||||
|
||||
static final int MAX_ITEMS = 100;
|
||||
static final int POSITION_STEP = 10;
|
||||
// 2000 per the editor spec — frontend maxlength and the i18n error message agree (#793).
|
||||
static final int MAX_NOTE_LENGTH = 2000;
|
||||
|
||||
private final JourneyItemRepository journeyItemRepository;
|
||||
private final GeschichteQueryService geschichteQueryService;
|
||||
private final DocumentService documentService;
|
||||
private final AuditService auditService;
|
||||
private final UserService userService;
|
||||
|
||||
@Transactional
|
||||
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
|
||||
Geschichte g = geschichteQueryService.findById(geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||
"Geschichte not found: " + geschichteId));
|
||||
|
||||
long count = journeyItemRepository.countByGeschichteId(geschichteId);
|
||||
if (count >= MAX_ITEMS) {
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
|
||||
"Journey has reached the maximum of 100 items");
|
||||
}
|
||||
|
||||
String note = normalizeNote(dto.getNote());
|
||||
|
||||
if (dto.getDocumentId() == null && note == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"At least one of documentId or note must be provided");
|
||||
}
|
||||
|
||||
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
|
||||
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||
}
|
||||
|
||||
Document doc = null;
|
||||
if (dto.getDocumentId() != null) {
|
||||
if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
"Document already in journey: " + dto.getDocumentId());
|
||||
}
|
||||
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
|
||||
}
|
||||
|
||||
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
|
||||
.map(max -> max + POSITION_STEP)
|
||||
.orElse(POSITION_STEP);
|
||||
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(g)
|
||||
.position(nextPosition)
|
||||
.document(doc)
|
||||
.note(note)
|
||||
.build();
|
||||
// saveAndFlush so the partial unique index on (geschichte_id, document_id)
|
||||
// fires here, not at commit — two concurrent appends can both pass the
|
||||
// exists() pre-check above, and the index is the atomic backstop (V74).
|
||||
JourneyItem saved;
|
||||
try {
|
||||
saved = journeyItemRepository.saveAndFlush(item);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// Only the dedup index earns the friendly 409 — any other integrity
|
||||
// failure (e.g. an FK violation on a concurrently deleted document)
|
||||
// must not be mislabeled as "already added".
|
||||
if (!isDuplicateDocumentViolation(e)) {
|
||||
throw e;
|
||||
}
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
"Document already in journey: " + dto.getDocumentId());
|
||||
}
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", saved.getId()));
|
||||
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
|
||||
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||
"Journey item not found: " + itemId));
|
||||
|
||||
// null = field absent from JSON → no-op
|
||||
Optional<String> noteField = dto.getNote();
|
||||
if (noteField == null) {
|
||||
return toView(item);
|
||||
}
|
||||
|
||||
String note = normalizeNote(noteField.orElse(null));
|
||||
|
||||
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
|
||||
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||
}
|
||||
|
||||
if (note == null && item.getDocumentId() == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Cannot clear note on an item that has no linked document");
|
||||
}
|
||||
|
||||
item.setNote(note);
|
||||
JourneyItem saved = journeyItemRepository.save(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(UUID geschichteId, UUID itemId) {
|
||||
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||
"Journey item not found: " + itemId));
|
||||
|
||||
journeyItemRepository.delete(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
|
||||
if (!geschichteQueryService.existsById(geschichteId)) {
|
||||
throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||
"Geschichte not found: " + geschichteId);
|
||||
}
|
||||
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
|
||||
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
|
||||
|
||||
if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Duplicate item IDs in reorder request");
|
||||
}
|
||||
|
||||
if (!existingIds.equals(new HashSet<>(requestedIds))) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Requested item IDs do not match the journey's existing items");
|
||||
}
|
||||
|
||||
if (requestedIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
|
||||
Map<UUID, JourneyItem> itemMap = new HashMap<>();
|
||||
for (JourneyItem item : items) {
|
||||
itemMap.put(item.getId(), item);
|
||||
}
|
||||
|
||||
List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
|
||||
for (int i = 0; i < requestedIds.size(); i++) {
|
||||
JourneyItem item = itemMap.get(requestedIds.get(i));
|
||||
item.setPosition((i + 1) * POSITION_STEP);
|
||||
toSave.add(item);
|
||||
}
|
||||
List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));
|
||||
|
||||
return reordered.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
public List<JourneyItemView> getItems(UUID geschichteId) {
|
||||
return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId)
|
||||
.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
DocumentSummary toSummary(Document doc) {
|
||||
String senderName = buildSenderName(doc);
|
||||
Set<Person> receivers = doc.getReceivers();
|
||||
String receiverName = buildCanonicalReceiverName(receivers);
|
||||
|
||||
return new DocumentSummary(
|
||||
doc.getId(),
|
||||
doc.getTitle(),
|
||||
doc.getDocumentDate(),
|
||||
doc.getMetaDateEnd(),
|
||||
doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
|
||||
senderName,
|
||||
receiverName,
|
||||
receivers != null ? receivers.size() : 0
|
||||
);
|
||||
}
|
||||
|
||||
JourneyItemView toView(JourneyItem item) {
|
||||
DocumentSummary docSummary = null;
|
||||
Document doc = item.getDocument();
|
||||
if (doc != null) {
|
||||
docSummary = toSummary(doc);
|
||||
}
|
||||
return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
|
||||
}
|
||||
|
||||
private static String buildSenderName(Document doc) {
|
||||
Person sender = doc.getSender();
|
||||
if (sender != null) {
|
||||
String name = PersonNameFormatter.join(sender.getFirstName(), sender.getLastName());
|
||||
if (!name.isBlank()) return name;
|
||||
}
|
||||
String senderText = doc.getSenderText();
|
||||
return (senderText != null && !senderText.isBlank()) ? senderText : null;
|
||||
}
|
||||
|
||||
private static String buildCanonicalReceiverName(Set<Person> receivers) {
|
||||
if (receivers == null || receivers.isEmpty()) return null;
|
||||
return receivers.stream()
|
||||
.min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
|
||||
.map(p -> {
|
||||
String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName());
|
||||
return name.isBlank() ? null : name;
|
||||
})
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof java.sql.SQLException sql) {
|
||||
return "23505".equals(sql.getSQLState());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String normalizeNote(String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
private static String sortKey(String s) {
|
||||
return s != null ? s : "";
|
||||
}
|
||||
|
||||
private AppUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
return userService.findByEmail(auth.getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Input for PATCH /api/geschichten/{id}/items/{itemId}.
|
||||
* Three-way semantics via Optional<String>:
|
||||
* null → field absent from JSON → leave note unchanged
|
||||
* Optional.empty() → {"note": null} → clear the note
|
||||
* Optional.of("x") → {"note": "x"} → set the note
|
||||
*
|
||||
* Jackson 3.x maps JSON null to Optional.empty(); absent fields keep the Java default (null).
|
||||
*/
|
||||
@Data
|
||||
public class JourneyItemUpdateDTO {
|
||||
private Optional<String> note = null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Read-model response for a JourneyItem. Never the JPA entity (which has a
|
||||
* Geschichte back-reference that would leak / hit LazyInitializationException).
|
||||
*/
|
||||
public record JourneyItemView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
|
||||
DocumentSummary document,
|
||||
/** Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output. */
|
||||
String note
|
||||
) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Input for PUT /api/geschichten/{id}/items/reorder. */
|
||||
@Data
|
||||
public class JourneyReorderDTO {
|
||||
private List<UUID> itemIds;
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.user.DisplayNameFormatter;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -49,8 +51,25 @@ public class Person {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String notes;
|
||||
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
// Most precise birth/death date known. Precision mirrors Document.metaDatePrecision:
|
||||
// the date column is nullable, the precision column is NOT NULL with UNKNOWN meaning
|
||||
// "no date" — the V76 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN).
|
||||
// DatePrecision is imported cross-domain from document/ by design (ADR-039).
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "birth_date_precision", nullable = false, length = 16)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private DatePrecision birthDatePrecision = DatePrecision.UNKNOWN;
|
||||
|
||||
private LocalDate deathDate;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "death_date_precision", nullable = false, length = 16)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private DatePrecision deathDatePrecision = DatePrecision.UNKNOWN;
|
||||
|
||||
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
|
||||
// Nullable for persons outside the curated family graph. Drives the
|
||||
|
||||
@@ -66,7 +66,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(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
|
||||
@@ -79,7 +82,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(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
|
||||
@@ -89,7 +95,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member, p.provisional
|
||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision, p.notes, p.family_member, p.provisional
|
||||
ORDER BY p.last_name ASC, p.first_name ASC
|
||||
""",
|
||||
nativeQuery = true)
|
||||
@@ -100,7 +106,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(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
|
||||
@@ -139,7 +148,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -16,6 +17,7 @@ import org.springframework.lang.Nullable;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
@@ -299,13 +301,20 @@ public class PersonService {
|
||||
}
|
||||
|
||||
private Person fromCanonical(PersonUpsertCommand cmd) {
|
||||
DatePrecisionPair none = new DatePrecisionPair(null, DatePrecision.UNKNOWN);
|
||||
LifeDates dates = degradeIfConflicting(
|
||||
yearPair(cmd.birthYear()), yearPair(cmd.deathYear()), none, none, cmd.sourceRef());
|
||||
DatePrecisionPair birth = dates.birth();
|
||||
DatePrecisionPair death = dates.death();
|
||||
Person person = personRepository.save(Person.builder()
|
||||
.sourceRef(cmd.sourceRef())
|
||||
.firstName(blankToNull(cmd.firstName()))
|
||||
.lastName(cmd.lastName())
|
||||
.notes(blankToNull(cmd.notes()))
|
||||
.birthYear(cmd.birthYear())
|
||||
.deathYear(cmd.deathYear())
|
||||
.birthDate(birth.date())
|
||||
.birthDatePrecision(birth.precision())
|
||||
.deathDate(death.date())
|
||||
.deathDatePrecision(death.precision())
|
||||
.generation(cmd.generation())
|
||||
.familyMember(cmd.familyMember())
|
||||
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
|
||||
@@ -328,8 +337,16 @@ public class PersonService {
|
||||
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()));
|
||||
LifeDates dates = degradeIfConflicting(
|
||||
preferHumanDate(existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear()),
|
||||
preferHumanDate(existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear()),
|
||||
new DatePrecisionPair(existing.getBirthDate(), existing.getBirthDatePrecision()),
|
||||
new DatePrecisionPair(existing.getDeathDate(), existing.getDeathDatePrecision()),
|
||||
cmd.sourceRef());
|
||||
existing.setBirthDate(dates.birth().date());
|
||||
existing.setBirthDatePrecision(dates.birth().precision());
|
||||
existing.setDeathDate(dates.death().date());
|
||||
existing.setDeathDatePrecision(dates.death().precision());
|
||||
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
|
||||
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
|
||||
existing.setPersonType(cmd.personType());
|
||||
@@ -356,6 +373,48 @@ public class PersonService {
|
||||
return existing != null ? existing : canonical;
|
||||
}
|
||||
|
||||
// Date + precision travel as one value so they can never go out of sync (ADR-039).
|
||||
record DatePrecisionPair(LocalDate date, DatePrecision precision) {}
|
||||
|
||||
record LifeDates(DatePrecisionPair birth, DatePrecisionPair death) {}
|
||||
|
||||
// The canonical path skips validateLifeDates (the form-only guard), so a conflicting
|
||||
// resolved pair would hit chk_person_birth_before_death at flush time and abort the
|
||||
// whole import batch with a raw 500. Degrade instead (REQ-IMP-001: never abort the
|
||||
// batch): keep the person's stored life dates — empty for a new person — and drop the
|
||||
// conflicting canonical refresh. A hand-entered side is preserved by construction,
|
||||
// since preferHumanDate returned it verbatim and it equals the stored value; two
|
||||
// stored values can never conflict with each other (they already satisfied the CHECK).
|
||||
static LifeDates degradeIfConflicting(DatePrecisionPair birth, DatePrecisionPair death,
|
||||
DatePrecisionPair existingBirth, DatePrecisionPair existingDeath,
|
||||
String sourceRef) {
|
||||
if (birth.date() == null || death.date() == null || !birth.date().isAfter(death.date())) {
|
||||
return new LifeDates(birth, death);
|
||||
}
|
||||
log.warn("Conflicting canonical life dates for {}: birth {} is after death {} — keeping stored values",
|
||||
sourceRef, birth.date(), death.date());
|
||||
return new LifeDates(existingBirth, existingDeath);
|
||||
}
|
||||
|
||||
// preferHuman for life dates (ADR-025 extension): a hand-entered date more precise than
|
||||
// the spreadsheet's year (DAY/MONTH/SEASON/RANGE/APPROX) is preserved on re-import; a
|
||||
// YEAR-precision or absent date is refreshed from the canonical year.
|
||||
static DatePrecisionPair preferHumanDate(LocalDate existingDate, DatePrecision existingPrecision,
|
||||
Integer canonicalYear) {
|
||||
boolean handEntered = existingDate != null && existingPrecision != null
|
||||
&& existingPrecision != DatePrecision.YEAR && existingPrecision != DatePrecision.UNKNOWN;
|
||||
if (handEntered) {
|
||||
return new DatePrecisionPair(existingDate, existingPrecision);
|
||||
}
|
||||
return yearPair(canonicalYear);
|
||||
}
|
||||
|
||||
private static DatePrecisionPair yearPair(Integer year) {
|
||||
return year != null
|
||||
? new DatePrecisionPair(LocalDate.of(year, 1, 1), DatePrecision.YEAR)
|
||||
: new DatePrecisionPair(null, DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
@@ -375,7 +434,8 @@ public class PersonService {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
|
||||
dto.getDeathDate(), dto.getDeathDatePrecision());
|
||||
Person person = Person.builder()
|
||||
.personType(dto.getPersonType())
|
||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||
@@ -383,31 +443,49 @@ public class PersonService {
|
||||
.lastName(dto.getLastName())
|
||||
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||
.birthYear(dto.getBirthYear())
|
||||
.deathYear(dto.getDeathYear())
|
||||
.birthDate(dto.getBirthDate())
|
||||
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
|
||||
.deathDate(dto.getDeathDate())
|
||||
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
|
||||
.generation(dto.getGeneration())
|
||||
.build();
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
private void validateYears(Integer birthYear, Integer deathYear) {
|
||||
if (birthYear != null && birthYear <= 0) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
|
||||
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
||||
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
|
||||
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
|
||||
LocalDate deathDate, DatePrecision deathPrecision) {
|
||||
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
|
||||
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
|
||||
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
||||
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
||||
"Birth date " + birthDate + " is after death date " + deathDate);
|
||||
}
|
||||
if (deathYear != null && deathYear <= 0) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
|
||||
}
|
||||
|
||||
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
|
||||
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||
side + " date is set but its precision is missing or UNKNOWN");
|
||||
}
|
||||
if (birthYear != null && deathYear != null && birthYear > deathYear) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
|
||||
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||
side + " date precision " + precision + " is set without a date");
|
||||
}
|
||||
}
|
||||
|
||||
private static DatePrecision normalizePrecision(DatePrecision precision) {
|
||||
return precision == null ? DatePrecision.UNKNOWN : precision;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
|
||||
dto.getDeathDate(), dto.getDeathDatePrecision());
|
||||
Person person = personRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||
person.setPersonType(dto.getPersonType());
|
||||
@@ -416,8 +494,10 @@ public class PersonService {
|
||||
person.setLastName(dto.getLastName());
|
||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||
person.setBirthYear(dto.getBirthYear());
|
||||
person.setDeathYear(dto.getDeathYear());
|
||||
person.setBirthDate(dto.getBirthDate());
|
||||
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
|
||||
person.setDeathDate(dto.getDeathDate());
|
||||
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
|
||||
// Form path: a human can clear generation back to null. Unlike the importer
|
||||
// which routes through preferHuman, we write the DTO value verbatim.
|
||||
person.setGeneration(dto.getGeneration());
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@@ -16,6 +19,13 @@ public interface PersonSummaryDTO {
|
||||
String getAlias();
|
||||
Integer getBirthYear();
|
||||
Integer getDeathYear();
|
||||
// Full date + precision alongside the derived years: list consumers that render
|
||||
// precise life dates (mention dropdown) read these; year-only consumers keep
|
||||
// the cheaper getBirthYear/getDeathYear.
|
||||
LocalDate getBirthDate();
|
||||
DatePrecision getBirthDatePrecision();
|
||||
LocalDate getDeathDate();
|
||||
DatePrecision getDeathDatePrecision();
|
||||
String getNotes();
|
||||
boolean isFamilyMember();
|
||||
boolean isProvisional();
|
||||
|
||||
@@ -5,8 +5,11 @@ import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.person.PersonType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
public class PersonUpdateDTO {
|
||||
@NotNull
|
||||
@@ -21,8 +24,10 @@ public class PersonUpdateDTO {
|
||||
private String alias;
|
||||
@Size(max = 5000)
|
||||
private String notes;
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
private LocalDate birthDate;
|
||||
private DatePrecision birthDatePrecision;
|
||||
private LocalDate deathDate;
|
||||
private DatePrecision deathDatePrecision;
|
||||
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
|
||||
// PersonGeneration so DB, DTO, and importer all read from one place.
|
||||
@Min(PersonGeneration.MIN_GENERATION)
|
||||
|
||||
@@ -96,7 +96,9 @@ public class RelationshipInferenceService {
|
||||
if (p == null) continue;
|
||||
List<RelationToken> path = shortestPaths.get(id);
|
||||
PersonNodeDTO node = new PersonNodeDTO(
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
||||
p.getId(), p.getDisplayName(),
|
||||
RelationshipService.yearOf(p.getBirthDate()),
|
||||
RelationshipService.yearOf(p.getDeathDate()),
|
||||
p.getGeneration(), p.isFamilyMember());
|
||||
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -66,7 +67,8 @@ public class RelationshipService {
|
||||
for (Person p : familyMembers) {
|
||||
familyIds.add(p.getId());
|
||||
nodes.add(new PersonNodeDTO(
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
||||
p.getId(), p.getDisplayName(),
|
||||
yearOf(p.getBirthDate()), yearOf(p.getDeathDate()),
|
||||
p.getGeneration(), true));
|
||||
}
|
||||
|
||||
@@ -155,6 +157,13 @@ public class RelationshipService {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
|
||||
// Stammbaum DTOs stay year-shaped: derive the year from the LocalDate, null-safe
|
||||
// for persons with no date entered (ADR-039, REQ-PERSON-DATE-01). Package-private
|
||||
// so RelationshipInferenceService shares the same derivation.
|
||||
static Integer yearOf(LocalDate date) {
|
||||
return date != null ? date.getYear() : null;
|
||||
}
|
||||
|
||||
private static void validateYears(Integer fromYear, Integer toYear) {
|
||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
||||
throw DomainException.badRequest(
|
||||
@@ -170,11 +179,11 @@ public class RelationshipService {
|
||||
p.getId(),
|
||||
rp.getId(),
|
||||
p.getDisplayName(),
|
||||
p.getBirthYear(),
|
||||
p.getDeathYear(),
|
||||
yearOf(p.getBirthDate()),
|
||||
yearOf(p.getDeathDate()),
|
||||
rp.getDisplayName(),
|
||||
rp.getBirthYear(),
|
||||
rp.getDeathYear(),
|
||||
yearOf(rp.getBirthDate()),
|
||||
yearOf(rp.getDeathDate()),
|
||||
r.getRelationType(),
|
||||
r.getFromYear(),
|
||||
r.getToYear(),
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public record NlQueryInterpretation(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<PersonHint> resolvedPersons,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<PersonHint> ambiguousPersons,
|
||||
LocalDate dateFrom,
|
||||
LocalDate dateTo,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<String> keywords,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
List<TagHint> resolvedTags,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String rawQuery,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
boolean keywordsApplied,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
boolean tagsApplied
|
||||
) {
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||
import org.raddatz.familienarchiv.document.SearchFilters;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.NameMatches;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class NlQueryParserService {
|
||||
|
||||
private static final int MIN_QUERY = 3;
|
||||
private static final int MAX_QUERY = 500;
|
||||
private static final int MAX_NAME_LENGTH = 200;
|
||||
private static final int MIN_TAG_TERM = 3;
|
||||
private static final int MAX_RESOLVED_TAGS = 10;
|
||||
|
||||
private final OllamaClient ollamaClient;
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final TagService tagService;
|
||||
|
||||
public NlSearchResponse search(String query, Pageable pageable) {
|
||||
if (query == null || query.length() < MIN_QUERY || query.length() > MAX_QUERY) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Query must be between " + MIN_QUERY + " and " + MAX_QUERY + " characters");
|
||||
}
|
||||
|
||||
OllamaExtraction ext = ollamaClient.parse(query);
|
||||
|
||||
List<String> personNames = ext.personNames() != null ? ext.personNames() : List.of();
|
||||
List<String> keywords = ext.keywords() != null ? ext.keywords() : List.of();
|
||||
|
||||
TagResolution tagResolution = resolveTags(keywords);
|
||||
List<TagHint> resolvedTagHints = tagResolution.hints();
|
||||
List<String> resolvedTagNames = tagResolution.tagNames();
|
||||
List<String> remainingKeywords = tagResolution.remaining();
|
||||
|
||||
NameResolution resolution = resolveNames(personNames);
|
||||
|
||||
if (!resolution.ambiguous().isEmpty()) {
|
||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||
List.of(), resolution.ambiguous(),
|
||||
ext.dateFrom(), ext.dateTo(),
|
||||
keywords, List.of(), ext.rawQuery(), false, false);
|
||||
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation);
|
||||
}
|
||||
|
||||
List<PersonHint> resolved = resolution.resolved();
|
||||
List<String> noMatchFragments = resolution.noMatchFragments();
|
||||
List<String> extraFragments = resolution.extraFragments();
|
||||
|
||||
boolean hadStructuredMatch = !resolvedTagHints.isEmpty() || !resolved.isEmpty();
|
||||
String text = buildText(remainingKeywords, noMatchFragments, extraFragments, ext.rawQuery(), hadStructuredMatch);
|
||||
|
||||
if (resolved.size() == 1 && isAnyRole(ext.personRole())) {
|
||||
UUID personId = resolved.get(0).id();
|
||||
DocumentSearchResult docs = documentService.searchDocumentsByPersonId(
|
||||
personId, ext.dateFrom(), ext.dateTo(), pageable);
|
||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), false, false);
|
||||
return new NlSearchResponse(docs, interpretation);
|
||||
}
|
||||
|
||||
UUID sender = buildSender(resolved, ext.personRole());
|
||||
UUID receiver = buildReceiver(resolved, ext.personRole());
|
||||
|
||||
boolean tagsApplied = !resolvedTagHints.isEmpty();
|
||||
TagOperator tagOperator = tagsApplied ? TagOperator.OR : TagOperator.AND;
|
||||
|
||||
SearchFilters filters = new SearchFilters(
|
||||
text.isBlank() ? null : text,
|
||||
ext.dateFrom(), ext.dateTo(),
|
||||
sender, receiver,
|
||||
resolvedTagNames, null,
|
||||
null, tagOperator, false);
|
||||
|
||||
DocumentSearchResult docs = documentService.searchDocuments(filters, DocumentSort.DATE, "desc", pageable);
|
||||
boolean keywordsApplied = !text.isBlank();
|
||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), keywordsApplied, tagsApplied);
|
||||
return new NlSearchResponse(docs, interpretation);
|
||||
}
|
||||
|
||||
private NameResolution resolveNames(List<String> personNames) {
|
||||
List<PersonHint> resolved = new ArrayList<>();
|
||||
List<PersonHint> ambiguous = new ArrayList<>();
|
||||
List<String> noMatchFragments = new ArrayList<>();
|
||||
List<String> extraFragments = new ArrayList<>();
|
||||
|
||||
int resolvedIndex = 0;
|
||||
for (String name : personNames) {
|
||||
if (name == null || name.length() > MAX_NAME_LENGTH) {
|
||||
log.debug("Skipping name fragment (too long or null): length={}", name == null ? 0 : name.length());
|
||||
continue;
|
||||
}
|
||||
NameMatches matches = personService.resolveByName(name);
|
||||
List<Person> direct = matches.direct();
|
||||
List<Person> partial = matches.partial();
|
||||
|
||||
if (direct.size() == 1) {
|
||||
Person p = direct.get(0);
|
||||
resolvedIndex++;
|
||||
if (resolvedIndex <= 2) {
|
||||
resolved.add(new PersonHint(p.getId(), p.getDisplayName()));
|
||||
} else {
|
||||
extraFragments.add(name);
|
||||
}
|
||||
} else if (direct.size() >= 2) {
|
||||
direct.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
|
||||
} else if (!partial.isEmpty()) {
|
||||
partial.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
|
||||
} else {
|
||||
noMatchFragments.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments);
|
||||
}
|
||||
|
||||
private TagResolution resolveTags(List<String> keywords) {
|
||||
LinkedHashSet<Tag> seen = new LinkedHashSet<>();
|
||||
List<String> remaining = new ArrayList<>();
|
||||
|
||||
for (String kw : keywords) {
|
||||
if (kw == null || kw.length() < MIN_TAG_TERM) {
|
||||
remaining.add(kw);
|
||||
continue;
|
||||
}
|
||||
List<Tag> matches = tagService.findByNameContaining(kw);
|
||||
if (matches.isEmpty()) {
|
||||
remaining.add(kw);
|
||||
} else {
|
||||
seen.addAll(matches);
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.size() > MAX_RESOLVED_TAGS) {
|
||||
log.debug("Keyword matched {} tags; capping at {}", seen.size(), MAX_RESOLVED_TAGS);
|
||||
}
|
||||
List<Tag> capped = seen.size() > MAX_RESOLVED_TAGS
|
||||
? new ArrayList<>(seen).subList(0, MAX_RESOLVED_TAGS)
|
||||
: new ArrayList<>(seen);
|
||||
|
||||
// safe: entities are detached here; mutation is for DTO projection only, no dirty-check fires
|
||||
tagService.resolveEffectiveColors(capped);
|
||||
|
||||
List<TagHint> hints = capped.stream()
|
||||
.map(t -> new TagHint(t.getId(), t.getName(), t.getColor()))
|
||||
.toList();
|
||||
List<String> tagNames = capped.stream().map(Tag::getName).toList();
|
||||
|
||||
return new TagResolution(hints, tagNames, remaining);
|
||||
}
|
||||
|
||||
private String buildText(List<String> keywords, List<String> noMatchFragments,
|
||||
List<String> extraFragments, String rawQuery, boolean hadStructuredMatch) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
parts.addAll(keywords);
|
||||
parts.addAll(noMatchFragments);
|
||||
parts.addAll(extraFragments);
|
||||
String text = String.join(" ", parts).strip();
|
||||
if (text.isBlank() && !hadStructuredMatch && rawQuery != null && !rawQuery.isBlank()) {
|
||||
return rawQuery;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private boolean isAnyRole(String role) {
|
||||
return role == null || "any".equals(role) || (!"sender".equals(role) && !"receiver".equals(role));
|
||||
}
|
||||
|
||||
private UUID buildSender(List<PersonHint> resolved, String role) {
|
||||
if (resolved.size() >= 2) return resolved.get(0).id();
|
||||
if (resolved.size() == 1 && "sender".equals(role)) return resolved.get(0).id();
|
||||
return null;
|
||||
}
|
||||
|
||||
private UUID buildReceiver(List<PersonHint> resolved, String role) {
|
||||
if (resolved.size() >= 2) return resolved.get(1).id();
|
||||
if (resolved.size() == 1 && "receiver".equals(role)) return resolved.get(0).id();
|
||||
return null;
|
||||
}
|
||||
|
||||
private record NameResolution(
|
||||
List<PersonHint> resolved,
|
||||
List<PersonHint> ambiguous,
|
||||
List<String> noMatchFragments,
|
||||
List<String> extraFragments
|
||||
) {}
|
||||
|
||||
private record TagResolution(
|
||||
List<TagHint> hints,
|
||||
List<String> tagNames,
|
||||
List<String> remaining
|
||||
) {}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/search/nl")
|
||||
@RequiredArgsConstructor
|
||||
public class NlSearchController {
|
||||
|
||||
private final NlQueryParserService nlQueryParserService;
|
||||
private final NlSearchRateLimiter rateLimiter;
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public NlSearchResponse search(@Valid @RequestBody NlSearchRequest request,
|
||||
Pageable pageable,
|
||||
@AuthenticationPrincipal UserDetails principal) {
|
||||
rateLimiter.checkAndConsume(principal.getUsername());
|
||||
return nlQueryParserService.search(request.query(), pageable);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties("app.nl-search.rate-limit")
|
||||
@Data
|
||||
public class NlSearchRateLimitProperties {
|
||||
private int maxRequestsPerMinute = 5;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.github.benmanes.caffeine.cache.LoadingCache;
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Service
|
||||
public class NlSearchRateLimiter {
|
||||
|
||||
private final LoadingCache<String, Bucket> byUser;
|
||||
private final int maxRequestsPerMinute;
|
||||
|
||||
public NlSearchRateLimiter(NlSearchRateLimitProperties props) {
|
||||
this.maxRequestsPerMinute = props.getMaxRequestsPerMinute();
|
||||
this.byUser = Caffeine.newBuilder()
|
||||
.expireAfterAccess(1, TimeUnit.MINUTES)
|
||||
.build(key -> newBucket(maxRequestsPerMinute));
|
||||
}
|
||||
|
||||
public void checkAndConsume(String userKey) {
|
||||
if (!byUser.get(userKey).tryConsume(1)) {
|
||||
throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_RATE_LIMITED,
|
||||
"NL search rate limit exceeded for user: " + userKey, 60L);
|
||||
}
|
||||
}
|
||||
|
||||
void resetForTest() {
|
||||
byUser.invalidateAll();
|
||||
}
|
||||
|
||||
private static Bucket newBucket(int limit) {
|
||||
return Bucket.builder()
|
||||
.addLimit(Bandwidth.builder()
|
||||
.capacity(limit)
|
||||
.refillGreedy(limit, Duration.ofMinutes(1))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record NlSearchRequest(
|
||||
@NotBlank
|
||||
@Size(min = 3, max = 500)
|
||||
String query
|
||||
) {
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||
|
||||
public record NlSearchResponse(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
DocumentSearchResult result,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
NlQueryInterpretation interpretation
|
||||
) {
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
public interface OllamaClient {
|
||||
OllamaExtraction parse(String query);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Raw structured output from Ollama after parsing and sanitising.
|
||||
* personRole is always one of "sender", "receiver", "any" — defensive parsing ensures this.
|
||||
*/
|
||||
record OllamaExtraction(
|
||||
List<String> personNames,
|
||||
String personRole,
|
||||
LocalDate dateFrom,
|
||||
LocalDate dateTo,
|
||||
List<String> keywords,
|
||||
String rawQuery
|
||||
) {
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
public interface OllamaHealthClient {
|
||||
boolean isHealthy();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ConfigurationProperties("app.ollama")
|
||||
@Data
|
||||
public class OllamaProperties {
|
||||
private String baseUrl;
|
||||
private String model;
|
||||
private int timeoutSeconds = 30;
|
||||
private int healthCheckTimeoutSeconds = 2;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record PersonHint(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String displayName
|
||||
) {
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Year;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class RestClientOllamaClient implements OllamaClient, OllamaHealthClient {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final Set<String> VALID_ROLES = Set.of("sender", "receiver", "any");
|
||||
private static final int MAX_NAME_LENGTH = 200;
|
||||
private static final int MAX_KEYWORD_LENGTH = 100;
|
||||
|
||||
private static final Map<String, Object> JSON_SCHEMA = Map.of(
|
||||
"type", "object",
|
||||
"required", List.of("personNames", "personRole", "keywords"),
|
||||
"properties", Map.of(
|
||||
"personNames", Map.of("type", "array", "items", Map.of("type", "string", "maxLength", MAX_NAME_LENGTH)),
|
||||
"personRole", Map.of("type", "string", "enum", List.of("sender", "receiver", "any")),
|
||||
"dateFrom", Map.of("type", List.of("string", "null"), "maxLength", 20),
|
||||
"dateTo", Map.of("type", List.of("string", "null"), "maxLength", 20),
|
||||
"keywords", Map.of("type", "array", "items", Map.of("type", "string", "maxLength", MAX_KEYWORD_LENGTH))
|
||||
)
|
||||
);
|
||||
|
||||
private final RestClient inferenceClient;
|
||||
private final RestClient healthClient;
|
||||
private final OllamaProperties props;
|
||||
|
||||
public RestClientOllamaClient(OllamaProperties props) {
|
||||
this.props = props;
|
||||
|
||||
HttpClient inferenceHttp = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
JdkClientHttpRequestFactory inferenceFactory = new JdkClientHttpRequestFactory(inferenceHttp);
|
||||
inferenceFactory.setReadTimeout(Duration.ofSeconds(props.getTimeoutSeconds()));
|
||||
this.inferenceClient = RestClient.builder()
|
||||
.baseUrl(props.getBaseUrl())
|
||||
.requestFactory(inferenceFactory)
|
||||
.build();
|
||||
|
||||
HttpClient healthHttp = HttpClient.newBuilder()
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.connectTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds()))
|
||||
.build();
|
||||
JdkClientHttpRequestFactory healthFactory = new JdkClientHttpRequestFactory(healthHttp);
|
||||
healthFactory.setReadTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds()));
|
||||
this.healthClient = RestClient.builder()
|
||||
.baseUrl(props.getBaseUrl())
|
||||
.requestFactory(healthFactory)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public OllamaExtraction parse(String query) {
|
||||
try {
|
||||
OllamaGenerateRequest request = new OllamaGenerateRequest(
|
||||
props.getModel(), query, JSON_SCHEMA, false);
|
||||
String responseBody = inferenceClient.post()
|
||||
.uri("/api/generate")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
return parseOllamaResponse(responseBody, query);
|
||||
} catch (DomainException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.warn("Ollama inference failed: {}", e.getClass().getSimpleName());
|
||||
throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE,
|
||||
"Ollama unavailable: " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isHealthy() {
|
||||
try {
|
||||
healthClient.get().uri("/api/tags").retrieve().toBodilessEntity();
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private OllamaExtraction parseOllamaResponse(String responseBody, String rawQuery) {
|
||||
try {
|
||||
OllamaGenerateResponse response = MAPPER.readValue(responseBody, OllamaGenerateResponse.class);
|
||||
String inner = response.response();
|
||||
if (inner == null || inner.isBlank()) {
|
||||
return fallbackExtraction(rawQuery);
|
||||
}
|
||||
RawOllamaOutput raw = MAPPER.readValue(inner, RawOllamaOutput.class);
|
||||
return toExtraction(raw, rawQuery);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse Ollama response: {}", e.getClass().getSimpleName());
|
||||
throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE,
|
||||
"Failed to parse Ollama response: " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
private OllamaExtraction toExtraction(RawOllamaOutput raw, String rawQuery) {
|
||||
List<String> names = raw.personNames() == null ? List.of() : raw.personNames().stream()
|
||||
.filter(n -> n != null && n.length() <= MAX_NAME_LENGTH)
|
||||
.toList();
|
||||
List<String> keywords = raw.keywords() == null ? List.of() : raw.keywords().stream()
|
||||
.filter(k -> k != null && k.length() <= MAX_KEYWORD_LENGTH)
|
||||
.toList();
|
||||
String role = sanitiseRole(raw.personRole());
|
||||
LocalDate dateFrom = parseDate(raw.dateFrom(), true);
|
||||
LocalDate dateTo = parseDate(raw.dateTo(), false);
|
||||
return new OllamaExtraction(names, role, dateFrom, dateTo, keywords, rawQuery);
|
||||
}
|
||||
|
||||
private OllamaExtraction fallbackExtraction(String rawQuery) {
|
||||
return new OllamaExtraction(List.of(), "any", null, null, List.of(), rawQuery);
|
||||
}
|
||||
|
||||
private String sanitiseRole(String role) {
|
||||
if (role != null && VALID_ROLES.contains(role)) {
|
||||
return role;
|
||||
}
|
||||
log.warn("Unexpected personRole from Ollama: {}", role);
|
||||
return "any";
|
||||
}
|
||||
|
||||
private LocalDate parseDate(String raw, boolean isFrom) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
try {
|
||||
return LocalDate.parse(raw, DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
} catch (DateTimeParseException ignored) {
|
||||
}
|
||||
try {
|
||||
int year = Integer.parseInt(raw.strip());
|
||||
if (year > 1000 && year < 3000) {
|
||||
return isFrom ? Year.of(year).atDay(1) : Year.of(year).atMonth(12).atEndOfMonth();
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record OllamaGenerateResponse(String response) {
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record RawOllamaOutput(
|
||||
@JsonProperty("personNames") List<String> personNames,
|
||||
@JsonProperty("personRole") String personRole,
|
||||
@JsonProperty("dateFrom") String dateFrom,
|
||||
@JsonProperty("dateTo") String dateTo,
|
||||
@JsonProperty("keywords") List<String> keywords
|
||||
) {
|
||||
}
|
||||
|
||||
private record OllamaGenerateRequest(
|
||||
String model,
|
||||
String prompt,
|
||||
Object format,
|
||||
boolean stream
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record TagHint(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String name,
|
||||
String color
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
/**
|
||||
* Kind of a curated {@link TimelineEvent}.
|
||||
*
|
||||
* <p>The string value names are a <strong>stable frontend styling contract</strong>: the
|
||||
* Svelte timeline components hard-code {@code "PERSONAL"} (family accent) and
|
||||
* {@code "HISTORICAL"} (muted world accent) as Tailwind class-map keys. There is no
|
||||
* mapping layer — renaming either value requires a coordinated frontend change. See
|
||||
* ADR-040.
|
||||
*/
|
||||
public enum EventType {
|
||||
/** A family/personal event (birth, wedding, move) — rendered with the family accent. */
|
||||
PERSONAL,
|
||||
/** A world/historical event providing context — rendered with the muted world accent. */
|
||||
HISTORICAL
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.BatchSize;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A curated event on the family timeline (Zeitstrahl). Unlike a {@link Document}, which is
|
||||
* OCR-derived, a {@code TimelineEvent} is authored by curators — hence the optimistic-lock
|
||||
* {@link #version} and the {@link #createdBy}/{@link #updatedBy} audit trail that
|
||||
* {@code Document} lacks.
|
||||
*
|
||||
* <p>The date block ({@link #eventDate}, {@link #precision}, {@link #eventDateEnd}) mirrors
|
||||
* {@code Document}'s so events and letters share one rendering path. The mirror applies to
|
||||
* the date block only — the audit footprint deliberately diverges (see ADR-040).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "timeline_events")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class TimelineEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String title;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private EventType type;
|
||||
|
||||
/** The most precise date known for the event. Always present — a curated event is never undated. */
|
||||
@Column(name = "event_date", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDate eventDate;
|
||||
|
||||
/**
|
||||
* Precision of {@link #eventDate}. Reuses {@code document.DatePrecision} (one rendering
|
||||
* path; see ADR-025 / ADR-040). Every value except {@code UNKNOWN} is legal for a curated
|
||||
* event — including {@code SEASON} ("Sommer 1914") and {@code APPROX} ("ca. 1914"). The DB
|
||||
* CHECK forbids exactly {@code UNKNOWN}; do not narrow it further.
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "date_precision", nullable = false, length = 16)
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private DatePrecision precision = DatePrecision.YEAR;
|
||||
|
||||
/** Range end — non-null <strong>iff</strong> {@link #precision} is {@code RANGE} (DB CHECK, both directions). */
|
||||
@Column(name = "event_date_end")
|
||||
private LocalDate eventDateEnd;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
/** People the event involves. */
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(name = "timeline_event_persons",
|
||||
joinColumns = @JoinColumn(name = "timeline_event_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "person_id"))
|
||||
@BatchSize(size = 50)
|
||||
@Builder.Default
|
||||
private Set<Person> persons = new HashSet<>();
|
||||
|
||||
/** Optional supporting letters linked to the event. */
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(name = "timeline_event_documents",
|
||||
joinColumns = @JoinColumn(name = "timeline_event_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
||||
@BatchSize(size = 50)
|
||||
@Builder.Default
|
||||
private Set<Document> documents = new HashSet<>();
|
||||
|
||||
/**
|
||||
* UUID of the {@code AppUser} who created the event. Bare UUID, no FK to {@code app_users}
|
||||
* (sidecar pattern — keeps {@code timeline} decoupled from {@code user}). Server-populated
|
||||
* from the session principal; never accepted from client input (authorship-forgery vector,
|
||||
* CWE-639 — see ADR-040).
|
||||
*/
|
||||
@Column(name = "created_by", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID createdBy;
|
||||
|
||||
@CreationTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* UUID of the {@code AppUser} who last edited the event. Populated from the session
|
||||
* principal in {@code TimelineEventService}; <strong>must be set before every
|
||||
* {@code save()}</strong> — {@code @UpdateTimestamp} on {@link #updatedAt} does NOT set this
|
||||
* automatically, so without an explicit set the timestamp advances while the "who" goes
|
||||
* stale. Same forgery rationale as {@link #createdBy}.
|
||||
*/
|
||||
@Column(name = "updated_by", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID updatedBy;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Optimistic-lock version for the multi-curator edit flow (#775). Object {@code Long}
|
||||
* (not primitive) so it is {@code null} before first persist; Hibernate sets {@code 0} on
|
||||
* insert. A concurrent-write conflict must be translated to {@code DomainException.conflict}
|
||||
* in the service layer (ADR-040) — otherwise it surfaces as HTTP 500 with Hibernate
|
||||
* internals (CWE-209).
|
||||
*/
|
||||
@Version
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long version;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface TimelineEventRepository extends JpaRepository<TimelineEvent, UUID> {
|
||||
// TODO(#777): findByPersonsContaining(Person) needed for the per-person filter
|
||||
}
|
||||
@@ -12,6 +12,3 @@ springdoc:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
|
||||
app:
|
||||
ollama:
|
||||
base-url: http://localhost:11434
|
||||
|
||||
@@ -130,18 +130,6 @@ app:
|
||||
# The loader maps columns by header name — no positional indices (see ADR-025).
|
||||
dir: ${IMPORT_DIR:/import}
|
||||
|
||||
ollama:
|
||||
base-url: http://ollama:11434
|
||||
model: qwen2.5:7b-instruct-q4_K_M
|
||||
# CPU inference: ~18s warm. Higher ceiling absorbs the cold model load on the
|
||||
# first query after an Ollama (re)start before OLLAMA_KEEP_ALIVE pins it.
|
||||
timeout-seconds: 60
|
||||
health-check-timeout-seconds: 2
|
||||
|
||||
nl-search:
|
||||
rate-limit:
|
||||
max-requests-per-minute: 5
|
||||
|
||||
ocr:
|
||||
sender-model:
|
||||
activation-threshold: 100
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Production pre-requisite — run BEFORE applying this migration:
|
||||
-- docker exec familienarchiv-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
|
||||
-- -c "SELECT COUNT(DISTINCT (geschichte_id, document_id)) FROM geschichten_documents;"'
|
||||
-- docker exec familienarchiv-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \
|
||||
-- --table=geschichten_documents \
|
||||
-- -f /tmp/pre_v72_backup_'"$(date +%Y%m%d)"'.sql'
|
||||
-- Take the dump even if geschichten_documents is empty — it captures the table DEFINITION
|
||||
-- for emergency reconstruction. The DROP TABLE is the only irreversible step; the
|
||||
-- INSERT...SELECT is a no-op when there is no data. No DDL rollback path exists after commit.
|
||||
--
|
||||
-- REVERSE PROCEDURE (if V72 must be rolled back): restore the pre-V72 dump, then re-derive
|
||||
-- the junction from the new table:
|
||||
-- INSERT INTO geschichten_documents (geschichte_id, document_id)
|
||||
-- SELECT geschichte_id, document_id FROM journey_items WHERE document_id IS NOT NULL;
|
||||
-- Note: the reconstructed junction FK is ON DELETE CASCADE per the original V58
|
||||
-- (NOT the new SET NULL of journey_items). Domain FKs target app_users (post-V60) —
|
||||
-- do NOT hand-type V58's verbatim "REFERENCES users" DDL nor copy journey_items' SET NULL
|
||||
-- into the reconstructed junction.
|
||||
--
|
||||
-- ASSUMPTION AS-001: The old geschichten_documents was an unordered Set — no curator order
|
||||
-- existed. Ordering by meta_date is a plausible default a Lesereise lets curators
|
||||
-- re-sequence. This is not a requirement; it is the best available approximation.
|
||||
--
|
||||
-- ASSUMPTION AS-002: Existing published Geschichten (STORYs) render the related-letters block;
|
||||
-- this block visibly degrades to generic links (loss of per-document title AND date) for ALL
|
||||
-- current readers during the stub window. Accepted because the reader follow-on is the
|
||||
-- next-priority blocking dependency.
|
||||
|
||||
-- Step 1: Add type discriminator column to geschichten
|
||||
ALTER TABLE geschichten
|
||||
ADD COLUMN type VARCHAR(50) DEFAULT 'STORY' NOT NULL;
|
||||
|
||||
-- Step 2: Create journey_items table
|
||||
CREATE TABLE journey_items (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
geschichte_id UUID NOT NULL,
|
||||
position INT NOT NULL,
|
||||
document_id UUID,
|
||||
note TEXT,
|
||||
CONSTRAINT pk_journey_items PRIMARY KEY (id),
|
||||
CONSTRAINT fk_journey_items_geschichte
|
||||
FOREIGN KEY (geschichte_id) REFERENCES geschichten(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_journey_items_document
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE SET NULL,
|
||||
CONSTRAINT chk_journey_item_not_empty
|
||||
CHECK (document_id IS NOT NULL OR note IS NOT NULL)
|
||||
);
|
||||
|
||||
-- Step 3: Index for ordered retrieval by geschichte + position
|
||||
CREATE INDEX idx_journey_items_geschichte_position
|
||||
ON journey_items (geschichte_id, position ASC);
|
||||
|
||||
-- Step 4: Migrate geschichten_documents → journey_items
|
||||
-- Positions are multiples of 1000 (headroom for drag-reorder).
|
||||
-- Ordered by meta_date ASC NULLS LAST, then documents.id ASC as deterministic tiebreaker.
|
||||
-- SELECT DISTINCT guards against duplicate junction rows producing duplicate journey items.
|
||||
INSERT INTO journey_items (id, geschichte_id, position, document_id)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
gd.geschichte_id,
|
||||
(ROW_NUMBER() OVER (
|
||||
PARTITION BY gd.geschichte_id
|
||||
ORDER BY d.meta_date ASC NULLS LAST, d.id ASC
|
||||
) * 1000)::INT AS position,
|
||||
gd.document_id
|
||||
FROM (
|
||||
SELECT DISTINCT geschichte_id, document_id
|
||||
FROM geschichten_documents
|
||||
) gd
|
||||
LEFT JOIN documents d ON d.id = gd.document_id;
|
||||
|
||||
-- Step 5: Drop the old junction table (irreversible — take the pg_dump first)
|
||||
DROP TABLE geschichten_documents;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Adds the two constraints that V72 deferred:
|
||||
-- 1. UNIQUE(geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
|
||||
-- Allows mid-transaction position swaps during reorder (checked at COMMIT, not per-row).
|
||||
-- Requires transaction-level or session-level connection pooling (prod uses PgBouncer
|
||||
-- in transaction mode — correct today; a future switch to statement-level would silently
|
||||
-- break deferred checking at COMMIT).
|
||||
-- 2. CHECK (position > 0) — defense against off-by-one in the append path.
|
||||
--
|
||||
-- MUST run in a single transaction; Flyway's default per-migration transaction satisfies this.
|
||||
-- Do NOT add executeInTransaction=false or any callback that splits this migration.
|
||||
|
||||
ALTER TABLE journey_items
|
||||
ADD CONSTRAINT uq_journey_items_geschichte_position
|
||||
UNIQUE (geschichte_id, position)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
ALTER TABLE journey_items
|
||||
ADD CONSTRAINT chk_journey_item_position
|
||||
CHECK (position > 0);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Two constraints the service-level checks need as atomic backstops:
|
||||
--
|
||||
-- 1. Partial unique index on (geschichte_id, document_id): the append dedup
|
||||
-- guard is a check-then-insert (existsByGeschichteIdAndDocumentId), so two
|
||||
-- concurrent appends of the same document can both pass the pre-check.
|
||||
-- The index rejects the second INSERT; JourneyItemService.append translates
|
||||
-- the DataIntegrityViolationException into the same 409
|
||||
-- JOURNEY_DOCUMENT_ALREADY_ADDED as the friendly pre-check.
|
||||
-- Partial (WHERE document_id IS NOT NULL) — note-only interludes must not collide.
|
||||
--
|
||||
-- 2. CHECK on note length: mirrors chk_text_length on transcription_blocks.
|
||||
-- 2000 is the spec'd limit — JourneyItemService.MAX_NOTE_LENGTH, the frontend
|
||||
-- maxlength, and the i18n error message all agree (#793).
|
||||
--
|
||||
-- Defensive cleanup first: a database that served writes on the base branch
|
||||
-- (no dedup guard, MAX_NOTE_LENGTH = 5000) can hold rows that would make the
|
||||
-- DDL below fail mid-migration and boot-loop the backend on a failed Flyway
|
||||
-- row. Both statements are no-ops on a clean database.
|
||||
|
||||
-- Keep the earliest-positioned row of each (geschichte, document) pair.
|
||||
DELETE FROM journey_items a
|
||||
USING journey_items b
|
||||
WHERE a.geschichte_id = b.geschichte_id
|
||||
AND a.document_id = b.document_id
|
||||
AND a.document_id IS NOT NULL
|
||||
AND a.position > b.position;
|
||||
|
||||
-- Clamp over-long notes written under the old 5000-char service limit.
|
||||
UPDATE journey_items SET note = left(note, 2000) WHERE length(note) > 2000;
|
||||
|
||||
CREATE UNIQUE INDEX uq_journey_items_geschichte_document
|
||||
ON journey_items (geschichte_id, document_id)
|
||||
WHERE document_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE journey_items
|
||||
ADD CONSTRAINT chk_journey_item_note_length
|
||||
CHECK (note IS NULL OR length(note) <= 2000);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- JOURNEY intros travel the verbatim (unsanitized) write path and get the same
|
||||
-- three-layer bound as journey notes: frontend maxlength, the
|
||||
-- GeschichteService.MAX_INTRO_LENGTH check, and this CHECK as the atomic backstop.
|
||||
-- STORY bodies are sanitized Tiptap HTML and stay unbounded on purpose.
|
||||
--
|
||||
-- The title needs no CHECK here — VARCHAR(255) (V58) already bounds it at the
|
||||
-- DB layer; the service-level check exists to turn that 500 into a friendly 400.
|
||||
|
||||
-- Defensive clamp first: intros written before this migration may exceed the
|
||||
-- cap. No-op on a clean database.
|
||||
UPDATE geschichten SET body = left(body, 4000)
|
||||
WHERE type = 'JOURNEY' AND length(body) > 4000;
|
||||
|
||||
ALTER TABLE geschichten
|
||||
ADD CONSTRAINT chk_geschichte_journey_intro_length
|
||||
CHECK (type <> 'JOURNEY' OR body IS NULL OR length(body) <= 4000);
|
||||
@@ -0,0 +1,42 @@
|
||||
-- V76: persons.birth_year/death_year (integer) → birth_date/death_date (date)
|
||||
-- plus NOT NULL precision columns mirroring documents.meta_date_precision.
|
||||
-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-039).
|
||||
-- One-way migration: rollback is a targeted pg_restore -t persons from the
|
||||
-- pre-deploy backup (see docs/DEPLOYMENT.md).
|
||||
|
||||
-- Pre-check (data quality gate — not a race guard): abort on corrupt year data
|
||||
-- before any DDL runs. Single-writer family archive, so no race window matters.
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year)
|
||||
THEN RAISE EXCEPTION 'V76 aborted: % persons have birth_year > death_year — fix data before migrating',
|
||||
(SELECT COUNT(*) FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year);
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM persons WHERE birth_year = 0 OR death_year = 0)
|
||||
THEN RAISE EXCEPTION 'V76 aborted: persons table contains birth_year=0 or death_year=0 rows — clean data before migrating';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE persons ADD COLUMN birth_date date;
|
||||
ALTER TABLE persons ADD COLUMN birth_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
||||
ALTER TABLE persons ADD COLUMN death_date date;
|
||||
ALTER TABLE persons ADD COLUMN death_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
||||
|
||||
UPDATE persons SET birth_date = make_date(birth_year, 1, 1), birth_date_precision = 'YEAR'
|
||||
WHERE birth_year IS NOT NULL;
|
||||
UPDATE persons SET death_date = make_date(death_year, 1, 1), death_date_precision = 'YEAR'
|
||||
WHERE death_year IS NOT NULL;
|
||||
|
||||
-- Named constraints: readable Postgres error messages when violated.
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_before_death
|
||||
CHECK (death_date IS NULL OR birth_date IS NULL OR birth_date <= death_date);
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_coherence
|
||||
CHECK ((birth_date IS NULL) = (birth_date_precision = 'UNKNOWN'));
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_values
|
||||
CHECK (birth_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_coherence
|
||||
CHECK ((death_date IS NULL) = (death_date_precision = 'UNKNOWN'));
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_values
|
||||
CHECK (death_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
||||
|
||||
ALTER TABLE persons DROP COLUMN birth_year;
|
||||
ALTER TABLE persons DROP COLUMN death_year;
|
||||
@@ -0,0 +1,64 @@
|
||||
-- V77: timeline domain foundation (Zeitstrahl) — curated timeline events.
|
||||
-- Forward-only, additive DDL. No rollback script: rollback requires manual DROP TABLE
|
||||
-- (timeline_event_documents, timeline_event_persons, then timeline_events). See ADR-040.
|
||||
--
|
||||
-- The date block (event_date / date_precision / event_date_end) mirrors documents' so events
|
||||
-- and letters share one rendering path. Two divergences from documents are INTENTIONAL and
|
||||
-- enforced here in Postgres (ADR-040):
|
||||
-- 1. The RANGE rule is a strict biconditional (event_date_end non-null IFF RANGE), unlike
|
||||
-- documents' open-ended ranges — a curated event always has a known, closed end.
|
||||
-- 2. date_precision <> 'UNKNOWN' — only OCR-inferred letters are ever undated; a curated
|
||||
-- event always has at least a year. SEASON and APPROX stay legal (Sommer/ca. 1914).
|
||||
|
||||
CREATE TABLE timeline_events (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(16) NOT NULL,
|
||||
event_date DATE NOT NULL,
|
||||
date_precision VARCHAR(16) NOT NULL DEFAULT 'YEAR',
|
||||
event_date_end DATE,
|
||||
description TEXT,
|
||||
created_by UUID NOT NULL,
|
||||
created_at TIMESTAMP,
|
||||
updated_by UUID NOT NULL,
|
||||
updated_at TIMESTAMP,
|
||||
version BIGINT,
|
||||
CONSTRAINT pk_timeline_events PRIMARY KEY (id),
|
||||
-- event_date_end is non-null IFF precision is RANGE (both directions).
|
||||
CONSTRAINT chk_timeline_event_range
|
||||
CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL)),
|
||||
-- Curated events are never undated. Forbids exactly UNKNOWN — every other
|
||||
-- DatePrecision value (DAY, MONTH, SEASON, YEAR, RANGE, APPROX) stays legal.
|
||||
CONSTRAINT chk_timeline_event_precision
|
||||
CHECK (date_precision <> 'UNKNOWN')
|
||||
);
|
||||
|
||||
-- Join table: events ↔ persons involved.
|
||||
CREATE TABLE timeline_event_persons (
|
||||
timeline_event_id UUID NOT NULL,
|
||||
person_id UUID NOT NULL,
|
||||
CONSTRAINT pk_timeline_event_persons PRIMARY KEY (timeline_event_id, person_id),
|
||||
CONSTRAINT fk_tep_event
|
||||
FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tep_person
|
||||
FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Join table: events ↔ supporting letters.
|
||||
CREATE TABLE timeline_event_documents (
|
||||
timeline_event_id UUID NOT NULL,
|
||||
document_id UUID NOT NULL,
|
||||
CONSTRAINT pk_timeline_event_documents PRIMARY KEY (timeline_event_id, document_id),
|
||||
CONSTRAINT fk_ted_event
|
||||
FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_ted_document
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes added up-front (avoid the V62 FK-index retrofit debt): the two query columns plus
|
||||
-- the inverse-side FK columns. timeline_event_id needs no extra index on either join table —
|
||||
-- it is the leading column of the composite PK, so the PK index already serves those lookups.
|
||||
CREATE INDEX idx_timeline_events_event_date ON timeline_events (event_date);
|
||||
CREATE INDEX idx_timeline_events_type ON timeline_events (type);
|
||||
CREATE INDEX idx_timeline_event_persons_person_id ON timeline_event_persons (person_id);
|
||||
CREATE INDEX idx_timeline_event_documents_document_id ON timeline_event_documents (document_id);
|
||||
@@ -402,6 +402,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + id).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
@@ -131,6 +131,28 @@ class DocumentLazyLoadingTest {
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() {
|
||||
// q + default sort + no other filters → the relevance fast path
|
||||
// (relevanceSortedPageFromSql), which loads documents by id outside any
|
||||
// transaction and must still deliver an initialized tags collection.
|
||||
Person sender = savedPerson("Hans", "FtSender");
|
||||
Tag tag = savedTag("FtTag");
|
||||
savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag));
|
||||
|
||||
SearchFilters textOnly = new SearchFilters(
|
||||
"Walter", null, null, null, null, null, null, null, null, false);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
textOnly, null, "DESC", PageRequest.of(0, 10));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(1);
|
||||
assertThatCode(() ->
|
||||
result.items().forEach(i -> i.tags().size()))
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
|
||||
Person sender = savedPerson("Hans", "SsSender");
|
||||
|
||||
@@ -81,7 +81,7 @@ class DocumentServiceSortTest {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any()))
|
||||
when(documentRepository.findByIdIn(any()))
|
||||
.thenReturn(List.of(doc(id1)));
|
||||
|
||||
documentService.searchDocuments(
|
||||
@@ -101,7 +101,7 @@ class DocumentServiceSortTest {
|
||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||
@@ -119,7 +119,7 @@ class DocumentServiceSortTest {
|
||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||
@@ -153,7 +153,7 @@ class DocumentServiceSortTest {
|
||||
List<Object[]> ftsRows = new ArrayList<>();
|
||||
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId)));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.filestorage.FileService;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
@@ -75,6 +76,7 @@ class DocumentServiceTest {
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
@Mock ApplicationEventPublisher eventPublisher;
|
||||
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
|
||||
// shared composition rather than a stub — the #726 single source of truth.
|
||||
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
|
||||
@@ -87,7 +89,7 @@ class DocumentServiceTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.existsById(id)).thenReturn(true);
|
||||
|
||||
documentService.deleteDocument(id);
|
||||
documentService.deleteDocument(id, UUID.randomUUID());
|
||||
|
||||
verify(documentRepository).deleteById(id);
|
||||
}
|
||||
@@ -97,7 +99,7 @@ class DocumentServiceTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.existsById(id)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> documentService.deleteDocument(id))
|
||||
assertThatThrownBy(() -> documentService.deleteDocument(id, UUID.randomUUID()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(id.toString());
|
||||
verify(documentRepository, never()).deleteById(any());
|
||||
@@ -2166,7 +2168,7 @@ class DocumentServiceTest {
|
||||
List<Object[]> ftsRows = new java.util.ArrayList<>();
|
||||
ftsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
@@ -2202,7 +2204,7 @@ class DocumentServiceTest {
|
||||
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
|
||||
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Raw-SQL constraint tests for geschichten — deliberately NOT @Transactional at
|
||||
* class level (see JourneyItemConstraintsTest for the rationale).
|
||||
*
|
||||
* The V75 CHECK is the atomic backstop for GeschichteService.MAX_INTRO_LENGTH on
|
||||
* the verbatim JOURNEY intro write path. STORY bodies are intentionally exempt.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class GeschichteConstraintsTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
|
||||
private UUID insertGeschichte(String type, String body) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO geschichten (id, title, body, status, type, created_at, updated_at) "
|
||||
+ "VALUES (?, ?, ?, 'DRAFT', ?, now(), now())",
|
||||
id, "Constraints-Test", body, type);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Test
|
||||
void journey_intro_check_rejects_4001_chars() {
|
||||
assertThatThrownBy(() -> insertGeschichte("JOURNEY", "x".repeat(4001)))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void journey_intro_check_accepts_exactly_4000_chars() {
|
||||
UUID id = insertGeschichte("JOURNEY", "x".repeat(4000));
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void story_bodies_are_not_constrained_by_the_intro_check() {
|
||||
UUID id = insertGeschichte("STORY", "<p>" + "x".repeat(4001) + "</p>");
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,13 @@ package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
@@ -21,22 +19,25 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.nullValue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(GeschichteController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -47,11 +48,9 @@ class GeschichteControllerTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@MockitoBean
|
||||
GeschichteService geschichteService;
|
||||
|
||||
@MockitoBean
|
||||
CustomUserDetailsService customUserDetailsService;
|
||||
@MockitoBean GeschichteService geschichteService;
|
||||
@MockitoBean JourneyItemService journeyItemService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||
|
||||
@@ -65,7 +64,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_returns200_forReader() throws Exception {
|
||||
when(geschichteService.list(any(), any(), any(), anyInt()))
|
||||
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
|
||||
.thenReturn(List.of(summaryStub("Story A")));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -101,13 +100,50 @@ class GeschichteControllerTest {
|
||||
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_passesDocumentIdFilterToService() throws Exception {
|
||||
UUID documentId = UUID.randomUUID();
|
||||
when(geschichteService.list(any(), any(), eq(documentId), anyInt()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/geschichten").param("documentId", documentId.toString()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(geschichteService).list(any(), any(), eq(documentId), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_passesLimitToService() throws Exception {
|
||||
when(geschichteService.list(any(), any(), any(), eq(5)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/geschichten").param("limit", "5"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(geschichteService).list(any(), any(), any(), eq(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_passesStatusFilterToService() throws Exception {
|
||||
when(geschichteService.list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/geschichten").param("status", "PUBLISHED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(geschichteService).list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt());
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns200_whenFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
|
||||
when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
.andExpect(status().isOk())
|
||||
@@ -119,7 +155,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getById(id))
|
||||
when(geschichteService.getView(id))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
@@ -151,7 +187,7 @@ class GeschichteControllerTest {
|
||||
void create_returns201_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(draft(id, "New"));
|
||||
.thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("New");
|
||||
@@ -179,7 +215,7 @@ class GeschichteControllerTest {
|
||||
void update_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(published(id, "Updated"));
|
||||
.thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -208,31 +244,202 @@ class GeschichteControllerTest {
|
||||
verify(geschichteService).delete(id);
|
||||
}
|
||||
|
||||
// ─── POST /api/geschichten/{id}/items ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void appendItem_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void appendItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns201_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Note\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value(itemId.toString()))
|
||||
.andExpect(jsonPath("$.position").value(10));
|
||||
}
|
||||
|
||||
// ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, "Updated"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Updated\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value("Updated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, null));
|
||||
|
||||
// Raw JSON — local objectMapper lacks JsonNullableModule
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\": null}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value(nullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns204_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(journeyItemService).delete(id, itemId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
|
||||
.when(journeyItemService).delete(id, itemId);
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[]}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void reorderItems_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));
|
||||
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[\"" + itemId + "\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(itemId.toString()));
|
||||
}
|
||||
|
||||
// ─── error mapping ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns409_on_position_conflict() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte published(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.body("<p>x</p>")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.build();
|
||||
private JourneyItemView itemViewStub(UUID id, int position, String note) {
|
||||
return new JourneyItemView(id, position, null, note);
|
||||
}
|
||||
|
||||
private Geschichte draft(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.build();
|
||||
private GeschichteView viewStub(UUID id, String title) {
|
||||
return viewStub(id, title, GeschichteStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
|
||||
return new GeschichteView(id, title, "<p>x</p>",
|
||||
status, GeschichteType.STORY,
|
||||
null, new HashSet<>(), List.of(),
|
||||
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
|
||||
}
|
||||
|
||||
/** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
|
||||
private GeschichteSummary summaryStub(String title) {
|
||||
return new GeschichteSummary() {
|
||||
public UUID getId() { return UUID.randomUUID(); }
|
||||
public String getTitle() { return title; }
|
||||
public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; }
|
||||
public GeschichteType getType() { return GeschichteType.STORY; }
|
||||
public AuthorSummary getAuthor() { return null; }
|
||||
public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
|
||||
public LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
|
||||
public String getBody() { return null; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.user.UserGroup;
|
||||
import org.raddatz.familienarchiv.user.UserGroupRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Verifies Geschichte HTTP behaviour end-to-end at the real servlet layer.
|
||||
*
|
||||
* <p>No {@code @Transactional} at class level — that would keep a session open and
|
||||
* mask LazyInitializationException caused by open-in-view: false. Each test seeds data
|
||||
* directly via repositories and relies on the service's own transaction boundaries.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class GeschichteHttpTest {
|
||||
|
||||
@LocalServerPort int port;
|
||||
@MockitoBean S3Client s3Client;
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired UserGroupRepository userGroupRepository;
|
||||
@Autowired PasswordEncoder passwordEncoder;
|
||||
|
||||
private RestTemplate http;
|
||||
private String baseUrl;
|
||||
|
||||
private static final String WRITER_EMAIL = "geschichten-http-writer@test.de";
|
||||
private static final String WRITER_PASSWORD = "pass!Geschichte1";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
http = noThrowRestTemplate();
|
||||
baseUrl = "http://localhost:" + port;
|
||||
geschichteRepository.deleteAll();
|
||||
appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete);
|
||||
appUserRepository.findByEmail(BLOG_WRITER_EMAIL).ifPresent(appUserRepository::delete);
|
||||
userGroupRepository.findByName("HttpTest-BlogWriters").ifPresent(userGroupRepository::delete);
|
||||
appUserRepository.save(AppUser.builder()
|
||||
.email(WRITER_EMAIL)
|
||||
.password(passwordEncoder.encode(WRITER_PASSWORD))
|
||||
.build());
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void list_returns_200_and_empty_array_when_no_stories_exist() {
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten", HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).isEqualTo("[]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_returns_200_and_does_not_500_when_stories_have_journey_items() {
|
||||
// Seed a JOURNEY directly — items are LAZY; without @Transactional(readOnly=true) +
|
||||
// Hibernate.initialize in getById() this would 500. list() uses a projection so it
|
||||
// must also never touch items.
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Reise durch die Briefe")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(journey)
|
||||
.position(1000)
|
||||
.note("Einleitung")
|
||||
.build();
|
||||
journey.getItems().add(item);
|
||||
geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten", HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).contains("Reise durch die Briefe");
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getById_returns_200_with_items_and_does_not_500_open_in_view_false() {
|
||||
// This test is the canonical guard against LazyInitializationException.
|
||||
// open-in-view: false means the Hibernate session is closed when Jackson serializes.
|
||||
// GeschichteService.getById() must initialize items inside its @Transactional boundary.
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Familiengeschichte")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
JourneyItem note = JourneyItem.builder()
|
||||
.geschichte(journey).position(1000).note("Prolog").build();
|
||||
JourneyItem note2 = JourneyItem.builder()
|
||||
.geschichte(journey).position(2000).note("Epilog").build();
|
||||
journey.getItems().add(note);
|
||||
journey.getItems().add(note2);
|
||||
Geschichte saved = geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody())
|
||||
.contains("Familiengeschichte")
|
||||
.contains("Prolog")
|
||||
.contains("Epilog");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returns_404_for_unknown_id() {
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + UUID.randomUUID(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||
assertThat(response.getBody()).contains("GESCHICHTE_NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE() {
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte draft = Geschichte.builder()
|
||||
.title("Geheimer Entwurf")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.author(writer)
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
Geschichte saved = geschichteRepository.save(draft);
|
||||
|
||||
// Writer lacks explicit BLOG_WRITE permission in the app_users table,
|
||||
// so from the service's perspective they're a reader.
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||
}
|
||||
|
||||
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void update_returns_200_and_serializes_items_open_in_view_false() {
|
||||
// Canonical guard for the write path: PATCH must not 500 when the response
|
||||
// is serialized after the service transaction closed. The raw entity carries
|
||||
// a dead lazy items proxy at that point — the endpoint must answer with a
|
||||
// view assembled inside the transaction.
|
||||
AppUser writer = blogWriter();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Reise vor dem Umbenennen")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
journey.getItems().add(JourneyItem.builder()
|
||||
.geschichte(journey).position(1000).note("Prolog").build());
|
||||
Geschichte saved = geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAs(BLOG_WRITER_EMAIL, BLOG_WRITER_PASSWORD);
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.PATCH,
|
||||
new HttpEntity<>("{\"title\":\"Reise nach dem Umbenennen\"}", csrfJsonHeaders(session)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody())
|
||||
.contains("Reise nach dem Umbenennen")
|
||||
.contains("Prolog");
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static final String BLOG_WRITER_EMAIL = "geschichten-http-blogwriter@test.de";
|
||||
private static final String BLOG_WRITER_PASSWORD = "pass!Geschichte2";
|
||||
|
||||
/** A user whose group actually grants BLOG_WRITE — unlike the plain writer above. */
|
||||
private AppUser blogWriter() {
|
||||
UserGroup group = userGroupRepository.save(UserGroup.builder()
|
||||
.name("HttpTest-BlogWriters")
|
||||
.permissions(new HashSet<>(Set.of("BLOG_WRITE")))
|
||||
.build());
|
||||
return appUserRepository.save(AppUser.builder()
|
||||
.email(BLOG_WRITER_EMAIL)
|
||||
.password(passwordEncoder.encode(BLOG_WRITER_PASSWORD))
|
||||
.groups(new HashSet<>(Set.of(group)))
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Session cookie + double-submit CSRF pair + JSON content type for write requests. */
|
||||
private HttpHeaders csrfJsonHeaders(String sessionId) {
|
||||
String xsrf = UUID.randomUUID().toString();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrf);
|
||||
headers.set("X-XSRF-TOKEN", xsrf);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String loginAsWriter() {
|
||||
return loginAs(WRITER_EMAIL, WRITER_PASSWORD);
|
||||
}
|
||||
|
||||
private String loginAs(String email, String password) {
|
||||
String xsrf = UUID.randomUUID().toString();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Cookie", "XSRF-TOKEN=" + xsrf);
|
||||
headers.set("X-XSRF-TOKEN", xsrf);
|
||||
String body = "{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}";
|
||||
ResponseEntity<String> resp = http.postForEntity(
|
||||
baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class);
|
||||
return extractFaSessionCookie(resp);
|
||||
}
|
||||
|
||||
private HttpHeaders sessionHeaders(String sessionId) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Cookie", "fa_session=" + sessionId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
||||
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||
if (setCookieHeader == null) return "";
|
||||
return setCookieHeader.stream()
|
||||
.filter(c -> c.startsWith("fa_session="))
|
||||
.map(c -> c.split(";")[0].substring("fa_session=".length()))
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
private RestTemplate noThrowRestTemplate() {
|
||||
// JDK HttpClient factory — the default HttpURLConnection factory cannot send PATCH.
|
||||
RestTemplate template = new RestTemplate(new JdkClientHttpRequestFactory());
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(ClientHttpResponse response) throws IOException {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemRepository;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class GeschichteListProjectionTest {
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
|
||||
AppUser author;
|
||||
AppUser otherAuthor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
geschichteRepository.deleteAll();
|
||||
author = appUserRepository.save(AppUser.builder()
|
||||
.email("author@test").password("pw").build());
|
||||
otherAuthor = appUserRepository.save(AppUser.builder()
|
||||
.email("other@test").password("pw").build());
|
||||
}
|
||||
|
||||
// ─── findSummaries returns only the requested status ─────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_returns_only_published_stories_when_effectiveStatus_is_PUBLISHED() {
|
||||
geschichteRepository.save(published("Veröffentlicht", author));
|
||||
geschichteRepository.save(draft("Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_carries_updatedAt_for_dashboard_relative_times() {
|
||||
// ReaderDraftsModule renders "bearbeitet vor X" from updatedAt — the
|
||||
// projection must carry it for drafts, where publishedAt is null.
|
||||
geschichteRepository.save(draft("Mein Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getUpdatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
|
||||
geschichteRepository.save(draft("Nur Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── AuthorSummary nested projection ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_exposes_nested_author_names_but_never_email() {
|
||||
AppUser richAuthor = appUserRepository.save(AppUser.builder()
|
||||
.firstName("Franz").lastName("Raddatz")
|
||||
.email("franz@raddatz.de").password("pw").build());
|
||||
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
|
||||
assertThat(a.getFirstName()).isEqualTo("Franz");
|
||||
assertThat(a.getLastName()).isEqualTo("Raddatz");
|
||||
// Design rule (GeschichteView.AuthorView javadoc): author projections never
|
||||
// expose email or group memberships to readers.
|
||||
assertThat(GeschichteSummary.AuthorSummary.class.getMethods())
|
||||
.extracting(java.lang.reflect.Method::getName)
|
||||
.doesNotContain("getEmail");
|
||||
}
|
||||
|
||||
// ─── GeschichteType is exposed ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_exposes_type_field() {
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Eine Reise")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(author)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
geschichteRepository.save(journey);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
// ─── authorId filter (own-drafts gate) ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_authorId_returns_only_own_drafts() {
|
||||
geschichteRepository.save(draft("Mein Entwurf", author));
|
||||
geschichteRepository.save(draft("Fremder Entwurf", otherAuthor));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf");
|
||||
}
|
||||
|
||||
// ─── personCount = 0 → no person filter ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_personCount_zero_ignores_personIds_and_returns_all() {
|
||||
geschichteRepository.save(published("A", author));
|
||||
geschichteRepository.save(published("B", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
}
|
||||
|
||||
// ─── personCount > 0 AND-semantics ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_one_personId_returns_only_linked_stories() {
|
||||
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
|
||||
|
||||
Geschichte withFranz = published("Franz story", author);
|
||||
withFranz.getPersons().add(franz);
|
||||
geschichteRepository.save(withFranz);
|
||||
|
||||
Geschichte withAnna = published("Anna story", author);
|
||||
withAnna.getPersons().add(anna);
|
||||
geschichteRepository.save(withAnna);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Franz story");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_with_two_personIds_uses_AND_semantics() {
|
||||
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
|
||||
|
||||
Geschichte both = published("Both", author);
|
||||
both.getPersons().add(franz);
|
||||
both.getPersons().add(anna);
|
||||
geschichteRepository.save(both);
|
||||
|
||||
Geschichte onlyFranz = published("Only Franz", author);
|
||||
onlyFranz.getPersons().add(franz);
|
||||
geschichteRepository.save(onlyFranz);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Both");
|
||||
}
|
||||
|
||||
// ─── documentId filter (JPQL EXISTS subquery) ────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_documentId_returns_journey_containing_that_document() {
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
Geschichte withDoc = geschichteRepository.save(journey("Reise mit Dokument", author));
|
||||
Geschichte withoutDoc = geschichteRepository.save(journey("Reise ohne Dokument", author));
|
||||
journeyItemRepository.save(JourneyItem.builder()
|
||||
.geschichte(withDoc).document(doc).position(1).build());
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, doc.getId());
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Reise mit Dokument");
|
||||
assertThat(result).extracting(GeschichteSummary::getTitle).doesNotContain("Reise ohne Dokument");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_with_unknown_documentId_returns_empty() {
|
||||
geschichteRepository.save(journey("Irgendeine Reise", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, UUID.randomUUID());
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte published(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte draft(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.author(writer)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte journey(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
|
||||
private List<UUID> sentinel() {
|
||||
return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GeschichteQueryServiceTest {
|
||||
|
||||
@Mock
|
||||
GeschichteRepository geschichteRepository;
|
||||
|
||||
@InjectMocks
|
||||
GeschichteQueryService geschichteQueryService;
|
||||
|
||||
@Test
|
||||
void existsById_returns_true_when_geschichte_exists() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.existsById(id)).thenReturn(true);
|
||||
|
||||
assertThat(geschichteQueryService.existsById(id)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void existsById_returns_false_when_geschichte_does_not_exist() {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.existsById(id)).thenReturn(false);
|
||||
|
||||
assertThat(geschichteQueryService.existsById(id)).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteView;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -39,6 +42,7 @@ class GeschichteServiceIntegrationTest {
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired GeschichteService geschichteService;
|
||||
@Autowired JourneyItemService journeyItemService;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@@ -76,11 +80,11 @@ class GeschichteServiceIntegrationTest {
|
||||
+ "<script>alert('xss')</script>");
|
||||
dto.setPersonIds(List.of(franz.getId()));
|
||||
|
||||
Geschichte created = geschichteService.create(dto);
|
||||
GeschichteView created = geschichteService.create(dto);
|
||||
|
||||
assertThat(created.getId()).isNotNull();
|
||||
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(created.getBody())
|
||||
assertThat(created.id()).isNotNull();
|
||||
assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(created.body())
|
||||
.contains("<strong>jeden Sonntag</strong>")
|
||||
.doesNotContain("<script>");
|
||||
|
||||
@@ -89,7 +93,7 @@ class GeschichteServiceIntegrationTest {
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
||||
|
||||
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
||||
UUID draftId = created.getId();
|
||||
UUID draftId = created.id();
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
|
||||
.hasMessageContaining("not found");
|
||||
|
||||
@@ -97,16 +101,17 @@ class GeschichteServiceIntegrationTest {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
|
||||
publishDto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
|
||||
assertThat(publishedGesch.getPublishedAt()).isNotNull();
|
||||
GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
|
||||
assertThat(publishedGesch.publishedAt()).isNotNull();
|
||||
|
||||
// Reader can now see and fetch it
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
||||
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
||||
Geschichte fetched = geschichteService.getById(draftId);
|
||||
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
|
||||
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
|
||||
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId());
|
||||
|
||||
// Delete as writer; join rows go with it
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
@@ -137,17 +142,17 @@ class GeschichteServiceIntegrationTest {
|
||||
|
||||
// No filter → all three
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// Single filter (Anna) → all three
|
||||
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactly(storyAB);
|
||||
|
||||
// AND: Bertha AND Carl → none (no story has both)
|
||||
@@ -174,7 +179,7 @@ class GeschichteServiceIntegrationTest {
|
||||
geschichteService.create(dto);
|
||||
|
||||
authenticateAs(writer2, Permission.BLOG_WRITE);
|
||||
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
@@ -185,7 +190,7 @@ class GeschichteServiceIntegrationTest {
|
||||
dto.setBody("<p>body</p>");
|
||||
dto.setPersonIds(personIds);
|
||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
return geschichteService.create(dto).getId();
|
||||
return geschichteService.create(dto).id();
|
||||
}
|
||||
|
||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||
|
||||
@@ -2,31 +2,28 @@ package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -37,7 +34,11 @@ import java.util.stream.Collectors;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -45,17 +46,13 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GeschichteServiceTest {
|
||||
|
||||
@Mock
|
||||
GeschichteRepository geschichteRepository;
|
||||
@Mock
|
||||
PersonService personService;
|
||||
@Mock
|
||||
DocumentService documentService;
|
||||
@Mock
|
||||
UserService userService;
|
||||
@Mock GeschichteRepository geschichteRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock UserService userService;
|
||||
@Mock JourneyItemService journeyItemService;
|
||||
|
||||
@InjectMocks
|
||||
GeschichteService geschichteService;
|
||||
@InjectMocks GeschichteService geschichteService;
|
||||
|
||||
AppUser writer;
|
||||
AppUser reader;
|
||||
@@ -96,7 +93,8 @@ class GeschichteServiceTest {
|
||||
|
||||
Geschichte result = geschichteService.getById(id);
|
||||
|
||||
assertThat(result).isSameAs(draft);
|
||||
assertThat(result.getId()).isEqualTo(id);
|
||||
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -108,7 +106,8 @@ class GeschichteServiceTest {
|
||||
|
||||
Geschichte result = geschichteService.getById(id);
|
||||
|
||||
assertThat(result).isSameAs(published);
|
||||
assertThat(result.getId()).isEqualTo(id);
|
||||
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -123,83 +122,207 @@ class GeschichteServiceTest {
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// ─── getView ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getView_returns_assembled_view_and_delegates_to_journeyItemService() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note");
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
|
||||
when(journeyItemService.getItems(id)).thenReturn(List.of(item));
|
||||
|
||||
GeschichteView view = geschichteService.getView(id);
|
||||
|
||||
assertThat(view.id()).isEqualTo(id);
|
||||
assertThat(view.items()).containsExactly(item);
|
||||
verify(journeyItemService).getItems(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getView_throws_NOT_FOUND_when_id_unknown() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.getView(id))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_uses_firstName_lastName() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("author@test")
|
||||
.firstName("Hans").lastName("Raddatz").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("Hans Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("anon@test").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("[Unbekannt]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_email_is_not_in_author_view() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("secret@test")
|
||||
.firstName("Max").lastName("M").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
// AuthorView exposes only id + displayName — no email field at all
|
||||
assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class);
|
||||
assertThat(result.author().displayName()).doesNotContain("secret@test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_persons_are_mapped_to_PersonView() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID personId = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setPersons(new HashSet<>(List.of(
|
||||
Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build()
|
||||
)));
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.persons()).hasSize(1);
|
||||
GeschichteView.PersonView pv = result.persons().iterator().next();
|
||||
assertThat(pv.id()).isEqualTo(personId);
|
||||
assertThat(pv.firstName()).isEqualTo("Franz");
|
||||
assertThat(pv.lastName()).isEqualTo("Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_items_are_passed_through() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
// ─── list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
|
||||
geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
// Status pinning lives inside the Specification; we assert end-to-end behaviour
|
||||
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
|
||||
// through the spec-aware repository method.
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
|
||||
|
||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
assertThat(out).hasSize(2);
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
|
||||
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(personId), null, 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
|
||||
void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID a = UUID.randomUUID();
|
||||
UUID b = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(a, b), null, 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_filters_by_documentId() {
|
||||
void list_passes_documentId_to_repository_as_journey_item_filter() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID documentId = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(), documentId, 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
|
||||
void list_passes_nil_uuid_sentinel_to_repository_when_no_person_filter_given() {
|
||||
// B2: when personIds is empty/null the service must pass a sentinel NIL UUID
|
||||
// so the IN() predicate is skipped without producing invalid empty-IN() SQL.
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
|
||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
|
||||
geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
UUID nilUUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
|
||||
verify(geschichteRepository).findSummaries(
|
||||
any(), any(), org.mockito.ArgumentMatchers.argThat(ids -> ids.contains(nilUUID)), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_caps_limit_at_max_when_caller_passes_huge_value() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of(mock(GeschichteSummary.class)));
|
||||
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);
|
||||
|
||||
assertThat(out).hasSizeLessThanOrEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("security: null status for blog writer returns PUBLISHED, never leaks drafts")
|
||||
void list_with_blog_writer_and_null_status_returns_PUBLISHED_not_all_drafts() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(
|
||||
eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("security: DRAFT status scopes to current user only")
|
||||
void list_with_DRAFT_status_scopes_to_current_user_not_all_authors() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(
|
||||
eq(GeschichteStatus.DRAFT), eq(writer.getId()), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
// ─── create ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -213,11 +336,11 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("My Story");
|
||||
dto.setBody("<p>plain text</p>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.getPublishedAt()).isNull();
|
||||
assertThat(saved.getAuthor()).isSameAs(writer);
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.publishedAt()).isNull();
|
||||
assertThat(saved.author().id()).isEqualTo(writer.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -231,9 +354,9 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("XSS attempt");
|
||||
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getBody())
|
||||
assertThat(saved.body())
|
||||
.contains("<p>safe</p>")
|
||||
.doesNotContain("<script>")
|
||||
.doesNotContain("onerror")
|
||||
@@ -252,9 +375,9 @@ class GeschichteServiceTest {
|
||||
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
|
||||
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getBody())
|
||||
assertThat(saved.body())
|
||||
.contains("<h2>Heading</h2>")
|
||||
.contains("<strong>bold</strong>")
|
||||
.contains("<em>italic</em>")
|
||||
@@ -277,28 +400,9 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("Linked");
|
||||
dto.setPersonIds(List.of(personId));
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getPersons()).containsExactly(person);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_resolves_documentIds_via_DocumentService() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(docId).build();
|
||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Linked doc");
|
||||
dto.setDocumentIds(List.of(docId));
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getDocuments()).containsExactly(doc);
|
||||
assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -315,6 +419,202 @@ class GeschichteServiceTest {
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_preserves_JOURNEY_type_from_dto() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("My Journey");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_defaults_to_STORY_when_type_is_null() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("My Story");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
|
||||
// The journey intro is plain text: JourneyReader renders it via Svelte text
|
||||
// interpolation (never {@html}), so the OWASP sanitizer's entity encoding
|
||||
// would corrupt real content ("Müller & Söhne" → "Müller & Söhne") and
|
||||
// re-encode cumulatively on every editor round-trip.
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Winterbriefe");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
dto.setBody("Müller & Söhne, Temperatur < 0");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.JOURNEY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("Temperatur < 0 & Schnee");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_still_sanitizes_STORY_body() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
}
|
||||
|
||||
// ─── length caps ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void create_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("x".repeat(256));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.create(dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_accepts_title_of_exactly_255_chars() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("x".repeat(255));
|
||||
|
||||
assertThat(geschichteService.create(dto).title()).hasSize(255);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("x".repeat(256));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Winterbriefe");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
dto.setBody("x".repeat(4001));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.create(dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_accepts_JOURNEY_intro_of_exactly_4000_chars() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Winterbriefe");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
dto.setBody("x".repeat(4000));
|
||||
|
||||
assertThat(geschichteService.create(dto).body()).hasSize(4000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.JOURNEY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("x".repeat(4001));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_does_not_apply_the_intro_cap_to_STORY_bodies() {
|
||||
// STORY bodies are sanitized Tiptap HTML and intentionally unbounded —
|
||||
// the 4000-char cap exists for the verbatim JOURNEY intro path only.
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>" + "x".repeat(4001) + "</p>");
|
||||
|
||||
assertThat(geschichteService.update(id, dto).body()).contains("<p>");
|
||||
}
|
||||
|
||||
// ─── update ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -330,10 +630,10 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
assertThat(saved.getPublishedAt()).isNotNull();
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
assertThat(saved.publishedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -349,10 +649,10 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setStatus(GeschichteStatus.DRAFT);
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.getPublishedAt()).isNull();
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.publishedAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -366,9 +666,46 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_rejects_type_change_with_409_GESCHICHTE_TYPE_IMMUTABLE() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_accepts_dto_carrying_the_unchanged_type() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setType(GeschichteType.STORY);
|
||||
dto.setTitle("Unverändert getypt");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
|
||||
assertThat(saved.title()).isEqualTo("Unverändert getypt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -426,7 +763,7 @@ class GeschichteServiceTest {
|
||||
.body("<p>body</p>")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -438,7 +775,7 @@ class GeschichteServiceTest {
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.publishedAt(LocalDateTime.now().minusHours(1))
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level.
|
||||
* A DataIntegrityViolationException inside a class-level @Transactional marks the tx
|
||||
* rollback-only and cascades into TransactionSystemException on teardown.
|
||||
* Each test inserts via jdbcTemplate and uses explicit SQL teardown.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class JourneyItemConstraintsTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
private UUID geschichteId;
|
||||
private UUID documentId;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbcTemplate.execute("DELETE FROM journey_items");
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Constraints-Test-Doc")
|
||||
.originalFilename("ct.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
documentId = doc.getId();
|
||||
Geschichte g = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Constraints-Test-Journey")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
geschichteId = g.getId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_constraint_is_deferrable_initially_deferred() {
|
||||
Boolean condeferrable = jdbcTemplate.queryForObject(
|
||||
"SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||
Boolean.class);
|
||||
Boolean condeferred = jdbcTemplate.queryForObject(
|
||||
"SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||
Boolean.class);
|
||||
assertThat(condeferrable).as("constraint must be deferrable").isTrue();
|
||||
assertThat(condeferred).as("constraint must be initially deferred").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_index_rejects_duplicate_document_per_geschichte() {
|
||||
// Atomic backstop for the service-level dedup pre-check (check-then-insert race).
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 20, documentId))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_index_allows_same_document_in_different_journeys() {
|
||||
Geschichte other = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Andere Lesereise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), other.getId(), 10, documentId);
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM journey_items WHERE document_id = ?", Integer.class, documentId);
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_index_allows_multiple_note_only_items() {
|
||||
// document_id IS NULL rows must not collide — the index is partial.
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, "erste Notiz");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 20, "zweite Notiz");
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void note_length_check_rejects_2001_chars() {
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, "x".repeat(2001)))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void note_length_check_accepts_exactly_2000_chars() {
|
||||
// Pins the boundary at the DB layer too — a future <= vs < migration edit
|
||||
// must fail here, not only in the mock-based service test.
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, "x".repeat(2000));
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void position_check_rejects_nonpositive() {
|
||||
UUID itemId = UUID.randomUUID();
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
itemId, geschichteId, 0, "test"))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_constraint_rejects_duplicate_position_per_geschichte() {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class JourneyItemDocumentDeleteTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@MockitoBean
|
||||
AuditService auditService;
|
||||
|
||||
@MockitoSpyBean
|
||||
DocumentRepository documentRepository;
|
||||
|
||||
@PersistenceContext
|
||||
EntityManager em;
|
||||
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired DocumentRepository docRepo;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
|
||||
Geschichte journey;
|
||||
Document doc;
|
||||
AppUser writer;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
writer = appUserRepository.save(AppUser.builder()
|
||||
.email("delete-test-writer@test")
|
||||
.password("hash")
|
||||
.build());
|
||||
doc = docRepo.save(Document.builder()
|
||||
.title("Testbrief")
|
||||
.originalFilename("testbrief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
journey = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Eine Lesereise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(writer.getEmail(), null,
|
||||
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
SecurityContextHolder.clearContext();
|
||||
reset(documentRepository);
|
||||
// Deletion order is FK-load-bearing: journey_items reference both documents
|
||||
// and geschichten, so children must be removed before their parents.
|
||||
journeyItemRepository.deleteAll();
|
||||
docRepo.deleteAll();
|
||||
geschichteRepository.deleteAll();
|
||||
appUserRepository.deleteAll();
|
||||
}
|
||||
|
||||
// ─── AC-1: headline ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_linked_via_note_less_item_deletes_item_not_500() {
|
||||
JourneyItem item = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
|
||||
assertThat(journeyItemRepository.findById(item.getId())).isEmpty();
|
||||
assertThat(docRepo.findById(doc.getId())).isEmpty();
|
||||
}
|
||||
|
||||
// ─── AC-2: note-carrying item survives as placeholder ─────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_preserves_note_carrying_item_as_placeholder() {
|
||||
JourneyItem item = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).note("curator context").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
JourneyItem surviving = journeyItemRepository.findById(item.getId()).orElseThrow();
|
||||
assertThat(surviving.getDocumentId()).isNull();
|
||||
assertThat(surviving.getNote()).isEqualTo("curator context");
|
||||
}
|
||||
|
||||
// ─── AC-3: note-only item untouched ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_does_not_affect_note_only_item() {
|
||||
JourneyItem noteOnly = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).note("Einleitung").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
JourneyItem reloaded = journeyItemRepository.findById(noteOnly.getId()).orElseThrow();
|
||||
assertThat(reloaded.getDocumentId()).isNull();
|
||||
assertThat(reloaded.getNote()).isEqualTo("Einleitung");
|
||||
}
|
||||
|
||||
// ─── AC-4: asymmetric multi-journey ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_applies_independently_per_referencing_item() {
|
||||
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Zweite Reise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
|
||||
JourneyItem noteLess = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
JourneyItem noteCarrying = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey2).position(10).document(doc).note("Begleittext").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemRepository.findById(noteLess.getId())).isEmpty();
|
||||
JourneyItem surviving = journeyItemRepository.findById(noteCarrying.getId()).orElseThrow();
|
||||
assertThat(surviving.getDocumentId()).isNull();
|
||||
assertThat(surviving.getNote()).isEqualTo("Begleittext");
|
||||
}
|
||||
|
||||
// ─── AC-5: rollback guard ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void listener_deletes_roll_back_when_document_delete_fails() {
|
||||
JourneyItem item = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
em.clear();
|
||||
|
||||
doThrow(new RuntimeException("simulated failure"))
|
||||
.when(documentRepository).deleteById(any());
|
||||
|
||||
assertThatThrownBy(() -> documentService.deleteDocument(doc.getId(), writer.getId()))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
|
||||
em.clear();
|
||||
assertThat(journeyItemRepository.findById(item.getId())).isPresent();
|
||||
}
|
||||
|
||||
// ─── AC-6: empty-string note boundary ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void empty_string_note_item_is_cascaded_whitespace_only_note_is_preserved() {
|
||||
// uq_journey_items_geschichte_document prevents two items with the same
|
||||
// (geschichte_id, document_id) in one journey — use two separate journeys.
|
||||
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Zweite Reise für AC-6")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
|
||||
UUID emptyNoteItemId = UUID.randomUUID();
|
||||
UUID whitespaceNoteItemId = UUID.randomUUID();
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
|
||||
emptyNoteItemId, journey.getId(), 10, doc.getId(), "");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
|
||||
whitespaceNoteItemId, journey2.getId(), 20, doc.getId(), " ");
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemRepository.findById(emptyNoteItemId)).isEmpty();
|
||||
JourneyItem whitespaceItem = journeyItemRepository.findById(whitespaceNoteItemId).orElseThrow();
|
||||
assertThat(whitespaceItem.getDocumentId()).isNull();
|
||||
assertThat(whitespaceItem.getNote()).isEqualTo(" ");
|
||||
}
|
||||
|
||||
// ─── Idempotency / no-collateral ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_in_zero_journeys_returns_no_collateral() {
|
||||
Document unlinked = docRepo.save(Document.builder()
|
||||
.title("Unverknüpfter Brief")
|
||||
.originalFilename("other.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
JourneyItem unrelated = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).note("unrelated note").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(unlinked.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
assertThat(docRepo.findById(unlinked.getId())).isEmpty();
|
||||
assertThat(journeyItemRepository.findById(unrelated.getId())).isPresent();
|
||||
assertThat(journeyItemRepository.count()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// ─── AC-7: audit — DOCUMENT_DELETED emitted, JOURNEY_ITEM_REMOVED absent ─
|
||||
|
||||
@Test
|
||||
void deleting_document_emits_document_audit_but_no_journey_item_audit() {
|
||||
journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.DOCUMENT_DELETED), eq(writer.getId()), eq(doc.getId()), any());
|
||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), any(), any(), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class JourneyItemIntegrationTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@MockitoBean
|
||||
AuditService auditService;
|
||||
|
||||
@PersistenceContext
|
||||
EntityManager em;
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
@Autowired JourneyItemService journeyItemService;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
|
||||
Geschichte journey;
|
||||
Document doc;
|
||||
AppUser writer;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
writer = appUserRepository.save(AppUser.builder()
|
||||
.email("journey-writer@test")
|
||||
.password("hash")
|
||||
.build());
|
||||
doc = documentRepository.save(Document.builder()
|
||||
.title("Testbrief")
|
||||
.originalFilename("testbrief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
journey = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Eine Lesereise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurity() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||
var authorities = java.util.Arrays.stream(permissions)
|
||||
.map(p -> new SimpleGrantedAuthority(p.name()))
|
||||
.toList();
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
|
||||
}
|
||||
|
||||
// ─── @OrderBy ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void items_are_returned_in_position_order_regardless_of_insertion_order() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
|
||||
// Distinct content per item — V74's partial unique index forbids the same
|
||||
// document twice in one journey, and ordering doesn't depend on it.
|
||||
JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
|
||||
JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).note("erstes").build();
|
||||
JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).note("zweites").build();
|
||||
managed.getItems().addAll(List.of(third, first, second));
|
||||
geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
List<Integer> positions = reloaded.getItems().stream().map(JourneyItem::getPosition).toList();
|
||||
|
||||
assertThat(positions).containsExactly(1000, 2000, 3000);
|
||||
}
|
||||
|
||||
// ─── Cascade ALL + orphanRemoval ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_geschichte_cascade_deletes_all_journey_items() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(1000).document(doc).build());
|
||||
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(2000).note("context").build());
|
||||
geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
UUID geschichteId = journey.getId();
|
||||
geschichteRepository.deleteById(geschichteId);
|
||||
em.flush();
|
||||
|
||||
assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void removing_item_from_items_list_triggers_orphan_removal() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
reloaded.getItems().removeIf(i -> i.getId().equals(itemId));
|
||||
geschichteRepository.save(reloaded);
|
||||
em.flush();
|
||||
|
||||
assertThat(journeyItemRepository.findById(itemId)).isEmpty();
|
||||
}
|
||||
|
||||
// ─── GeschichteType round-trip ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void type_persists_as_JOURNEY_and_roundtrips() {
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
assertThat(reloaded.getType()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void type_defaults_to_STORY_for_new_geschichten() {
|
||||
Geschichte story = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Erinnerung")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
Geschichte reloaded = geschichteRepository.findById(story.getId()).orElseThrow();
|
||||
assertThat(reloaded.getType()).isEqualTo(GeschichteType.STORY);
|
||||
}
|
||||
|
||||
// ─── Note-only item (document_id IS NULL) ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void note_only_item_persists_without_document() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem note = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).note("Eine kurze Einleitung.").build();
|
||||
managed.getItems().add(note);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID noteId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
JourneyItem reloaded = journeyItemRepository.findById(noteId).orElseThrow();
|
||||
assertThat(reloaded.getDocumentId()).isNull();
|
||||
assertThat(reloaded.getNote()).isEqualTo("Eine kurze Einleitung.");
|
||||
}
|
||||
|
||||
// ─── Document-backed item exposes documentId ──────────────────────────────
|
||||
|
||||
@Test
|
||||
void document_backed_item_exposes_document_uuid_via_getDocumentId() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).document(doc).build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
JourneyItem reloaded = journeyItemRepository.findById(itemId).orElseThrow();
|
||||
assertThat(reloaded.getDocumentId()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
// ─── ON DELETE SET NULL ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_sets_item_document_to_null_not_delete_item() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).document(doc).note("still here").build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
|
||||
// Route through service so the DocumentDeletingEvent fires and the listener
|
||||
// removes note-less items before ON DELETE SET NULL acts on note-carrying rows.
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow();
|
||||
assertThat(surviving.getDocumentId()).isNull();
|
||||
assertThat(surviving.getNote()).isEqualTo("still here");
|
||||
}
|
||||
|
||||
// ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ─────────
|
||||
|
||||
@Test
|
||||
void saving_item_with_neither_document_nor_note_violates_check_constraint() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem empty = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).build();
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
journeyItemRepository.save(empty);
|
||||
journeyItemRepository.flush();
|
||||
}).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.append — end-to-end persistence ──────────────────
|
||||
|
||||
@Test
|
||||
void append_persists_item_at_position_10() {
|
||||
// Arrange: authenticate as a user with BLOG_WRITE
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("First stop");
|
||||
|
||||
// Act
|
||||
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Assert: item exists in DB at position 10
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
assertThat(view.note()).isEqualTo("First stop");
|
||||
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||
assertThat(persisted).hasSize(1);
|
||||
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||
assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_document_persists_and_rejects_duplicate() {
|
||||
// Covers the document branch of append, including the duplicate guard —
|
||||
// the derived exists query must resolve document.id, which the transient
|
||||
// getDocumentId() getter on JourneyItem shadows for Spring Data.
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(doc.getId());
|
||||
|
||||
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(view.document()).isNotNull();
|
||||
assertThat(view.document().id()).isEqualTo(doc.getId());
|
||||
|
||||
JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO();
|
||||
duplicate.setDocumentId(doc.getId());
|
||||
assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate))
|
||||
.hasFieldOrPropertyWithValue("code",
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
|
||||
}
|
||||
|
||||
// ─── STORY-type Geschichten hold journey items (#795) ────────────────────
|
||||
|
||||
@Test
|
||||
void story_type_can_hold_journey_items_through_service() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
Geschichte story = savedStory();
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(doc.getId());
|
||||
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).id()).isEqualTo(appended.id());
|
||||
assertThat(items.get(0).document().id()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void v72_migrated_story_items_keep_position_order_and_are_removable() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
Geschichte story = savedStory();
|
||||
Document docB = documentRepository.save(Document.builder()
|
||||
.title("Zweiter Brief").originalFilename("b.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
Document docC = documentRepository.save(Document.builder()
|
||||
.title("Dritter Brief").originalFilename("c.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
|
||||
// V72 inserted journey_items rows directly with position gaps — mirror that
|
||||
// by writing through the repository instead of the service.
|
||||
JourneyItem first = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(story).position(10).document(doc).build());
|
||||
JourneyItem second = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(story).position(20).document(docB).build());
|
||||
JourneyItem third = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(story).position(30).document(docC).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemService.getItems(story.getId()))
|
||||
.extracting(JourneyItemView::position)
|
||||
.containsExactly(10, 20, 30);
|
||||
|
||||
journeyItemService.delete(story.getId(), second.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemService.getItems(story.getId()))
|
||||
.extracting(JourneyItemView::id)
|
||||
.containsExactly(first.getId(), third.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void story_item_with_deleted_document_survives_and_remains_deletable() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
Geschichte story = savedStory();
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(doc.getId());
|
||||
// The note keeps chk_journey_item_not_empty satisfied once ON DELETE
|
||||
// SET NULL clears document_id — a note-less item would block the
|
||||
// document delete at the DB instead.
|
||||
dto.setNote("Begleittext");
|
||||
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Route through service so the DocumentDeletingEvent fires (V72 cascade fix).
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).document()).isNull();
|
||||
|
||||
journeyItemService.delete(story.getId(), appended.id());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemService.getItems(story.getId())).isEmpty();
|
||||
}
|
||||
|
||||
private Geschichte savedStory() {
|
||||
return geschichteRepository.save(Geschichte.builder()
|
||||
.title("Eine Geschichte")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.STORY)
|
||||
.build());
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
|
||||
|
||||
@Test
|
||||
void reorder_swaps_positions_atomically() {
|
||||
// Arrange: append two items (pos 10, pos 20)
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO();
|
||||
dto1.setNote("Item one");
|
||||
JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1);
|
||||
|
||||
JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO();
|
||||
dto2.setNote("Item two");
|
||||
JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2);
|
||||
|
||||
assertThat(item1View.position()).isEqualTo(10);
|
||||
assertThat(item2View.position()).isEqualTo(20);
|
||||
|
||||
// Act: reorder with [item2, item1]
|
||||
JourneyReorderDTO reorderDto = new JourneyReorderDTO();
|
||||
reorderDto.setItemIds(List.of(item2View.id(), item1View.id()));
|
||||
List<JourneyItemView> reordered = journeyItemService.reorder(journey.getId(), reorderDto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Assert: item2 is now at position 10, item1 is at position 20
|
||||
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||
assertThat(persisted).hasSize(2);
|
||||
assertThat(persisted.get(0).getId()).isEqualTo(item2View.id());
|
||||
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||
assertThat(persisted.get(1).getId()).isEqualTo(item1View.id());
|
||||
assertThat(persisted.get(1).getPosition()).isEqualTo(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import org.postgresql.util.PSQLException;
|
||||
import org.postgresql.util.PSQLState;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JourneyItemServiceTest {
|
||||
|
||||
@Mock JourneyItemRepository journeyItemRepository;
|
||||
@Mock GeschichteQueryService geschichteQueryService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock UserService userService;
|
||||
|
||||
@InjectMocks JourneyItemService journeyItemService;
|
||||
|
||||
UUID geschichteId = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID actorId = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setupAuth() {
|
||||
AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
|
||||
lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
|
||||
lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("test@test.de", null,
|
||||
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
|
||||
}
|
||||
|
||||
// ─── toSummary — name composition ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void toSummary_uses_linked_person_firstName_lastName() {
|
||||
Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
|
||||
Document doc = makeDoc(docId, sender, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_falls_back_to_senderText_when_no_person() {
|
||||
Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Familie Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_returns_null_senderName_when_neither_person_nor_text() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(0);
|
||||
assertThat(summary.receiverName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
|
||||
Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
|
||||
Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
|
||||
Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(2);
|
||||
assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_SEASON_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.SEASON);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_APPROX_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.APPROX);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
|
||||
}
|
||||
|
||||
// ─── append ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void append_to_empty_journey_starts_at_10() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_after_reorder_continues_from_max_position() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
|
||||
JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_neither_documentId_nor_note() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("documentId or note");
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_note_trims_to_empty_and_no_document() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote(" \n ");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
|
||||
// 2000 is the spec'd limit (frontend maxlength + i18n message agree) — see #793.
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("x".repeat(2001));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_accepts_note_of_exactly_2000_chars() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "x".repeat(2000));
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("x".repeat(2000));
|
||||
|
||||
assertThat(journeyItemService.append(geschichteId, dto).note()).hasSize(2000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns404_when_documentId_does_not_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(documentService.findSummaryByIdInternal(docId))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_when_100_items_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_when_document_already_in_journey() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_to_STORY_type_creates_journey_item() {
|
||||
Geschichte story = story(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(false);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
when(documentService.findSummaryByIdInternal(docId)).thenReturn(doc);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(savedItemWithDoc(itemId, story, 10, doc, null));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
assertThat(view.document().id()).isEqualTo(docId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_to_STORY_type_respects_capacity_cap() {
|
||||
Geschichte story = story(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_to_STORY_type_rejects_duplicate_document() {
|
||||
Geschichte story = story(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cap_is_COUNT_based_not_MAX_position_based() {
|
||||
// 99 rows with MAX(position)=2000 should still accept the 100th append
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
|
||||
JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
|
||||
// Two concurrent appends can both pass the exists() pre-check; the partial
|
||||
// unique index then rejects the second INSERT at flush. The service must
|
||||
// translate that into the same friendly 409 as the pre-check.
|
||||
// Uses PSQLException with SQLState 23505 — the real payload Postgres delivers.
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
||||
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
|
||||
PSQLState.UNIQUE_VIOLATION);
|
||||
when(journeyItemRepository.saveAndFlush(any()))
|
||||
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
|
||||
"could not execute statement", psqlEx));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(UUID.randomUUID());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_maps_psql_sqlstate_23505_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
|
||||
// B1: the dedup check must use PSQLException.getSQLState() == "23505", not
|
||||
// constraint-name string matching — constraint renames must not regress this.
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
||||
|
||||
// Simulate a real Postgres unique-violation: PSQLException with SQLState 23505
|
||||
// wrapped by Spring's DataIntegrityViolationException.
|
||||
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
|
||||
PSQLState.UNIQUE_VIOLATION);
|
||||
org.springframework.dao.DataIntegrityViolationException dive =
|
||||
new org.springframework.dao.DataIntegrityViolationException("could not execute statement", psqlEx);
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenThrow(dive);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(UUID.randomUUID());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() throws Exception {
|
||||
// An FK violation (document deleted between load and flush) must NOT be
|
||||
// translated into "already added" — only the dedup unique index (23505) earns that 409.
|
||||
// FK violations arrive as PSQLException with SQLState 23503 (foreign_key_violation).
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
||||
PSQLException psqlEx = new PSQLException("foreign key violation", PSQLState.FOREIGN_KEY_VIOLATION);
|
||||
when(journeyItemRepository.saveAndFlush(any()))
|
||||
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
|
||||
"could not execute statement", psqlEx));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(UUID.randomUUID());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_audits_JOURNEY_ITEM_ADDED() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
journeyItemService.append(geschichteId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── updateNote ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateNote_absent_leaves_note_unchanged() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Original note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
// note is null by default — absent from JSON, no-op
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isEqualTo("Original note");
|
||||
verify(journeyItemRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_null_clears_note_when_document_is_present() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.empty());
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_string_sets_note() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||
item.setNote(null);
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("New note"));
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isEqualTo("New note");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_null_returns400_when_item_has_no_document() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_whitespace_only_including_newlines_stored_as_null() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("\n \n"));
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void patch_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("x".repeat(2001)));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_auditsNoteUpdate() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("New note"));
|
||||
journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void patch_returns404_when_item_belongs_to_different_journey() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("text"));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||
}
|
||||
|
||||
// ─── delete ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void delete_returns404_when_item_already_deleted() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_no_audit_when_item_not_found() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
journeyItemService.delete(geschichteId, itemId);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── reorder ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void reorder_unknownGeschichteId_throws404() {
|
||||
UUID unknownId = UUID.randomUUID();
|
||||
// geschichteQueryService is not lenient-stubbed for unknownId → returns false
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_itemIds_contain_duplicates() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id1)); // duplicate
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_itemId_belongs_to_different_journey() {
|
||||
UUID foreignId = UUID.randomUUID();
|
||||
UUID localId = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(foreignId));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_ids_have_extra_items() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id2));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns200_when_empty_on_empty_journey() {
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of());
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
List<JourneyItemView> result = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_empty_on_nonempty_journey() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns_items_in_new_order_starting_at_10() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 20, null, "A");
|
||||
JourneyItem item2 = savedItem(id2, journey, 10, null, "B");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1, id2));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item2, item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id2)); // want id1 first
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(2);
|
||||
assertThat(views.get(0).id()).isEqualTo(id1);
|
||||
assertThat(views.get(0).position()).isEqualTo(10);
|
||||
assertThat(views.get(1).id()).isEqualTo(id2);
|
||||
assertThat(views.get(1).position()).isEqualTo(20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_identical_order_returns200() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1));
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(1);
|
||||
assertThat(views.get(0).position()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_of_grandfathered_over_cap_journey_succeeds() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
// 130-item journey — reorder with all 130 IDs must succeed despite > 100 cap
|
||||
List<UUID> ids = new java.util.ArrayList<>();
|
||||
List<JourneyItem> items = new java.util.ArrayList<>();
|
||||
for (int i = 1; i <= 130; i++) {
|
||||
UUID id = UUID.randomUUID();
|
||||
ids.add(id);
|
||||
items.add(savedItem(id, journey, i * 10, null, "item " + i));
|
||||
}
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(new HashSet<>(ids));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(items);
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(ids);
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(130);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_audits_JOURNEY_ITEMS_REORDERED() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1));
|
||||
journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte journey(UUID id) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title("Test Journey")
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte story(UUID id) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title("Test Story")
|
||||
.type(GeschichteType.STORY)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build();
|
||||
}
|
||||
|
||||
private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||
return JourneyItem.builder()
|
||||
.id(id)
|
||||
.geschichte(g)
|
||||
.position(position)
|
||||
.document(null) // no document entity to avoid LAZY issues in unit tests
|
||||
.note(note)
|
||||
.build();
|
||||
}
|
||||
|
||||
private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.id(id)
|
||||
.geschichte(g)
|
||||
.position(position)
|
||||
.document(doc)
|
||||
.note(note)
|
||||
.build();
|
||||
return item;
|
||||
}
|
||||
|
||||
private Document makeDoc(UUID id, Person sender, List<Person> receivers, String senderText, String receiverText) {
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Test Doc")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.senderText(senderText)
|
||||
.receiverText(receiverText)
|
||||
.sender(sender)
|
||||
.build();
|
||||
doc.setReceivers(new HashSet<>(receivers));
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Verifies V76: persons.birth_year/death_year (integer) become
|
||||
* birth_date/death_date (date) + *_date_precision columns, with backfill to
|
||||
* YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality
|
||||
* pre-check that aborts the migration on corrupt year data.
|
||||
*
|
||||
* <p>Runs Flyway programmatically (no Spring context): each test gets its own
|
||||
* database so the staged migrate-to-V75 → seed → migrate-to-latest flow and
|
||||
* the abort cases cannot interfere with each other.
|
||||
*/
|
||||
class PersonBirthDeathMigrationTest {
|
||||
|
||||
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
|
||||
private static final AtomicInteger DB_COUNTER = new AtomicInteger();
|
||||
|
||||
private String dbUrl;
|
||||
|
||||
@BeforeAll
|
||||
static void startContainer() {
|
||||
POSTGRES.start();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopContainer() {
|
||||
POSTGRES.stop();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void createFreshDatabase() throws SQLException {
|
||||
String dbName = "mig_v76_" + DB_COUNTER.incrementAndGet();
|
||||
try (Connection conn = DriverManager.getConnection(
|
||||
baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("CREATE DATABASE " + dbName);
|
||||
}
|
||||
dbUrl = baseUrl(dbName);
|
||||
}
|
||||
|
||||
@Test
|
||||
void precheck_abortsWhenBirthYearAfterDeathYear() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("Corrupt", 1950, 1940);
|
||||
|
||||
assertThatThrownBy(this::migrateToLatest)
|
||||
.hasMessageContaining("V76 aborted")
|
||||
.hasMessageContaining("birth_year > death_year");
|
||||
}
|
||||
|
||||
@Test
|
||||
void precheck_abortsWhenYearZeroPresent() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("Zero", 0, null);
|
||||
|
||||
assertThatThrownBy(this::migrateToLatest)
|
||||
.hasMessageContaining("V76 aborted")
|
||||
.hasMessageContaining("birth_year=0 or death_year=0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_birthOnly_becomesYearPrecisionDate_deathStaysUnknown() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("BirthOnly", 1901, null);
|
||||
|
||||
migrateToLatest();
|
||||
|
||||
LifeDates row = lifeDates("BirthOnly");
|
||||
assertThat(row.birthDate()).hasToString("1901-01-01");
|
||||
assertThat(row.birthPrecision()).isEqualTo("YEAR");
|
||||
assertThat(row.deathDate()).isNull();
|
||||
assertThat(row.deathPrecision()).isEqualTo("UNKNOWN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_deathOnly_becomesYearPrecisionDate_birthStaysUnknown() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("DeathOnly", null, 1944);
|
||||
|
||||
migrateToLatest();
|
||||
|
||||
LifeDates row = lifeDates("DeathOnly");
|
||||
assertThat(row.birthDate()).isNull();
|
||||
assertThat(row.birthPrecision()).isEqualTo("UNKNOWN");
|
||||
assertThat(row.deathDate()).hasToString("1944-01-01");
|
||||
assertThat(row.deathPrecision()).isEqualTo("YEAR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("NoDates", null, null);
|
||||
|
||||
migrateToLatest();
|
||||
|
||||
LifeDates row = lifeDates("NoDates");
|
||||
assertThat(row.birthDate()).isNull();
|
||||
assertThat(row.birthPrecision()).isEqualTo("UNKNOWN");
|
||||
assertThat(row.deathDate()).isNull();
|
||||
assertThat(row.deathPrecision()).isEqualTo("UNKNOWN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_neverProducesBirthDateAfterDeathDate() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("SameYear", 1901, 1901);
|
||||
seedPerson("Ordered", 1899, 1972);
|
||||
|
||||
migrateToLatest();
|
||||
|
||||
assertThat(countWhere("birth_date IS NOT NULL AND death_date IS NOT NULL AND birth_date > death_date"))
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("Schema", 1901, 1944);
|
||||
|
||||
migrateToLatest();
|
||||
|
||||
assertThat(columnExists("birth_year")).isFalse();
|
||||
assertThat(columnExists("death_year")).isFalse();
|
||||
assertThat(columnExists("birth_date")).isTrue();
|
||||
assertThat(columnExists("death_date")).isTrue();
|
||||
for (String constraint : new String[]{
|
||||
"chk_person_birth_before_death",
|
||||
"chk_person_birth_date_precision_coherence",
|
||||
"chk_person_birth_date_precision_values",
|
||||
"chk_person_death_date_precision_coherence",
|
||||
"chk_person_death_date_precision_values"}) {
|
||||
assertThat(constraintExists(constraint)).as(constraint).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static String baseUrl(String dbName) {
|
||||
return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName;
|
||||
}
|
||||
|
||||
private void migrateTo(String targetVersion) {
|
||||
flywayBuilder().target(targetVersion).load().migrate();
|
||||
}
|
||||
|
||||
private void migrateToLatest() {
|
||||
flywayBuilder().load().migrate();
|
||||
}
|
||||
|
||||
private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() {
|
||||
return Flyway.configure()
|
||||
.dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.placeholders(Map.of("grafanaDbPassword", "test-only"));
|
||||
}
|
||||
|
||||
private void seedPerson(String lastName, Integer birthYear, Integer deathYear) throws SQLException {
|
||||
try (Connection conn = connect();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"INSERT INTO persons (id, last_name, person_type, family_member, provisional, birth_year, death_year) "
|
||||
+ "VALUES (gen_random_uuid(), ?, 'PERSON', false, false, ?, ?)")) {
|
||||
stmt.setString(1, lastName);
|
||||
stmt.setObject(2, birthYear);
|
||||
stmt.setObject(3, deathYear);
|
||||
stmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private record LifeDates(Object birthDate, String birthPrecision, Object deathDate, String deathPrecision) {}
|
||||
|
||||
private LifeDates lifeDates(String lastName) throws SQLException {
|
||||
try (Connection conn = connect();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT birth_date, birth_date_precision, death_date, death_date_precision "
|
||||
+ "FROM persons WHERE last_name = ?")) {
|
||||
stmt.setString(1, lastName);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
assertThat(rs.next()).as("person %s exists", lastName).isTrue();
|
||||
return new LifeDates(
|
||||
rs.getObject("birth_date"),
|
||||
rs.getString("birth_date_precision"),
|
||||
rs.getObject("death_date"),
|
||||
rs.getString("death_date_precision"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long countWhere(String condition) throws SQLException {
|
||||
try (Connection conn = connect();
|
||||
Statement stmt = conn.createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM persons WHERE " + condition)) {
|
||||
rs.next();
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean columnExists(String columnName) throws SQLException {
|
||||
try (Connection conn = connect();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT COUNT(*) FROM information_schema.columns "
|
||||
+ "WHERE table_schema = 'public' AND table_name = 'persons' AND column_name = ?")) {
|
||||
stmt.setString(1, columnName);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getInt(1) > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean constraintExists(String constraintName) throws SQLException {
|
||||
try (Connection conn = connect();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) {
|
||||
stmt.setString(1, constraintName);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getInt(1) > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Connection connect() throws SQLException {
|
||||
return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAlias;
|
||||
@@ -22,6 +23,7 @@ import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -215,6 +217,14 @@ class PersonControllerTest {
|
||||
public String getAlias() { return null; }
|
||||
public Integer getBirthYear() { return null; }
|
||||
public Integer getDeathYear() { return null; }
|
||||
public java.time.LocalDate getBirthDate() { return null; }
|
||||
public org.raddatz.familienarchiv.document.DatePrecision getBirthDatePrecision() {
|
||||
return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
|
||||
}
|
||||
public java.time.LocalDate getDeathDate() { return null; }
|
||||
public org.raddatz.familienarchiv.document.DatePrecision getDeathDatePrecision() {
|
||||
return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
|
||||
}
|
||||
public String getNotes() { return null; }
|
||||
public boolean isFamilyMember() { return false; }
|
||||
public boolean isProvisional() { return false; }
|
||||
@@ -572,18 +582,53 @@ class PersonControllerTest {
|
||||
void createPerson_returns200_withAllSixFields() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz")
|
||||
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
|
||||
.alias("Oma Maria")
|
||||
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
|
||||
.deathDate(LocalDate.of(1975, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
|
||||
.notes("Some notes").build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||
"\"alias\":\"Oma Maria\"," +
|
||||
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"DAY\"," +
|
||||
"\"deathDate\":\"1975-01-01\",\"deathDatePrecision\":\"YEAR\"," +
|
||||
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.firstName").value("Maria"))
|
||||
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
||||
.andExpect(jsonPath("$.birthYear").value(1901));
|
||||
.andExpect(jsonPath("$.birthDate").value("1901-03-14"))
|
||||
.andExpect(jsonPath("$.birthDatePrecision").value("DAY"));
|
||||
}
|
||||
|
||||
// ─── #773: malformed date payloads return structured 400s, not Jackson traces ──
|
||||
// Jackson rejects unknown enum values by default. Verified 2026-06-12: the only
|
||||
// DeserializationFeature hit in src/main is RestClientOcrClient's private ObjectMapper
|
||||
// (OCR HTTP client) — the Spring MVC mapper has no READ_UNKNOWN_ENUM_VALUES_* override.
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400WithStructuredErrorCode_whenPrecisionEnumInvalid() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
|
||||
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"INVALID_VALUE\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400WithStructuredErrorCode_whenBirthDateNotADate() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
|
||||
"\"birthDate\":\"not-a-date\",\"birthDatePrecision\":\"DAY\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
// ─── Phase 1.2: @Size constraints ─────────────────────────────────────────
|
||||
|
||||
@@ -5,7 +5,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -97,24 +99,120 @@ class PersonImportUpsertTest {
|
||||
assertThat(result.getNotes()).isEqualTo("Nichte von Herbert");
|
||||
}
|
||||
|
||||
// ─── life dates (ADR-025 extension via preferHumanDate, #773) ─────────────
|
||||
|
||||
@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));
|
||||
void upsertBySourceRef_preservesDayPrecisionDate_onReimportWithDifferentYear() {
|
||||
// A human entered the exact birthday in-app; the spreadsheet only knows a year.
|
||||
Person handDated = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
|
||||
.birthDate(LocalDate.of(1890, 3, 14)).birthDatePrecision(DatePrecision.DAY).build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.birthYear(1888).deathYear(1965)
|
||||
.birthYear(1888)
|
||||
.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
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 3, 14));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_preservesMonthPrecisionDate_onReimport() {
|
||||
Person handDated = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
|
||||
.deathDate(LocalDate.of(1944, 11, 1)).deathDatePrecision(DatePrecision.MONTH).build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.deathYear(1945)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 1));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.MONTH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_refreshesYearPrecisionDate_whenSpreadsheetYearChanges() {
|
||||
// YEAR precision means "the importer's year" — a corrected spreadsheet year wins.
|
||||
Person yearOnly = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
|
||||
.birthDate(LocalDate.of(1890, 1, 1)).birthDatePrecision(DatePrecision.YEAR).build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(yearOnly));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.birthYear(1888)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1888, 1, 1));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_fillsEmptyDateAtYearPrecision_onReimport() {
|
||||
Person noDates = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.deathYear(1965)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_keepsDatesEmpty_whenSpreadsheetHasNoYear() {
|
||||
Person noDates = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isNull();
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
assertThat(result.getDeathDate()).isNull();
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_translatesYearToDate_onFirstImport() {
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.birthYear(1890).deathYear(1965)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -199,4 +297,70 @@ class PersonImportUpsertTest {
|
||||
|
||||
assertThat(result.getGeneration()).isEqualTo(3);
|
||||
}
|
||||
|
||||
// ─── conflicting canonical life dates degrade instead of hitting the DB CHECK ──
|
||||
// (chk_person_birth_before_death would abort the whole batch — REQ-IMP-001)
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_dropsBothDates_whenCanonicalBirthAfterDeath_newPerson() {
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.birthYear(1950).deathYear(1949)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isNull();
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
assertThat(result.getDeathDate()).isNull();
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_keepsHandEnteredBirth_andDropsConflictingCanonicalDeath() {
|
||||
// A human entered an exact birthday; the spreadsheet's death year lies before it.
|
||||
// The hand-entered side must survive, the conflicting canonical refresh is dropped.
|
||||
Person handDated = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
|
||||
.birthDate(LocalDate.of(1950, 6, 1)).birthDatePrecision(DatePrecision.DAY).build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.deathYear(1949)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1950, 6, 1));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(result.getDeathDate()).isNull();
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_keepsExistingYearDates_whenCanonicalRefreshConflicts() {
|
||||
Person existing = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
|
||||
.birthDate(LocalDate.of(1900, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
|
||||
.deathDate(LocalDate.of(1980, 1, 1)).deathDatePrecision(DatePrecision.YEAR).build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.birthYear(1990).deathYear(1985)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1980, 1, 1));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -910,4 +913,146 @@ class PersonRepositoryTest {
|
||||
.setParameter(1, blockId).getSingleResult();
|
||||
assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
|
||||
}
|
||||
|
||||
// ─── #773: PersonSummaryDTO year projection from birth_date/death_date ──────
|
||||
|
||||
@Test
|
||||
void findAllWithDocumentCount_derivesYearsFromDates_nullSafe() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Maria").lastName("Datiert")
|
||||
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
|
||||
.build());
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Nora").lastName("Undatiert")
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();
|
||||
|
||||
PersonSummaryDTO dated = all.stream()
|
||||
.filter(p -> "Datiert".equals(p.getLastName())).findFirst().orElseThrow();
|
||||
assertThat(dated.getBirthYear()).isEqualTo(1901);
|
||||
assertThat(dated.getDeathYear()).isNull();
|
||||
PersonSummaryDTO undated = all.stream()
|
||||
.filter(p -> "Undatiert".equals(p.getLastName())).findFirst().orElseThrow();
|
||||
assertThat(undated.getBirthYear()).isNull();
|
||||
assertThat(undated.getDeathYear()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithDocumentCount_groupByPath_derivesYearsFromDates() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Herbert").lastName("Gruppiert")
|
||||
.birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
|
||||
.deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Gruppiert");
|
||||
|
||||
assertThat(found).hasSize(1);
|
||||
assertThat(found.get(0).getBirthYear()).isEqualTo(1899);
|
||||
assertThat(found.get(0).getDeathYear()).isEqualTo(1972);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByFilter_derivesYearsFromDates() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Filtriert").lastName("Person")
|
||||
.birthDate(LocalDate.of(1920, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> found = personRepository.findByFilter(
|
||||
null, null, null, null, false, "Filtriert", 10, 0);
|
||||
|
||||
assertThat(found).hasSize(1);
|
||||
assertThat(found.get(0).getBirthYear()).isEqualTo(1920);
|
||||
assertThat(found.get(0).getDeathYear()).isNull();
|
||||
}
|
||||
|
||||
// ─── #773 follow-up: full date + precision exposed on the summary projection ──
|
||||
// (the mention dropdown renders precise life dates from the list endpoint)
|
||||
|
||||
@Test
|
||||
void findAllWithDocumentCount_exposesDateAndPrecisionFields() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Maria").lastName("Praezise")
|
||||
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
|
||||
.deathDate(LocalDate.of(1972, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
|
||||
.build());
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Nora").lastName("Datenlos")
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();
|
||||
|
||||
PersonSummaryDTO dated = all.stream()
|
||||
.filter(p -> "Praezise".equals(p.getLastName())).findFirst().orElseThrow();
|
||||
assertThat(dated.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
|
||||
assertThat(dated.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(dated.getDeathDate()).isEqualTo(LocalDate.of(1972, 1, 1));
|
||||
assertThat(dated.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
PersonSummaryDTO undated = all.stream()
|
||||
.filter(p -> "Datenlos".equals(p.getLastName())).findFirst().orElseThrow();
|
||||
assertThat(undated.getBirthDate()).isNull();
|
||||
assertThat(undated.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
assertThat(undated.getDeathDate()).isNull();
|
||||
assertThat(undated.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithDocumentCount_exposesDateAndPrecisionFields() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Herbert").lastName("Suchbar")
|
||||
.birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
|
||||
.deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Suchbar");
|
||||
|
||||
assertThat(found).hasSize(1);
|
||||
assertThat(found.get(0).getBirthDate()).isEqualTo(LocalDate.of(1899, 1, 1));
|
||||
assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1972, 6, 12));
|
||||
assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findTopByDocumentCount_exposesDateAndPrecisionFields() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Top").lastName("Dokumentiert")
|
||||
.birthDate(LocalDate.of(1910, 5, 1)).birthDatePrecision(DatePrecision.MONTH)
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> top = personRepository.findTopByDocumentCount(10);
|
||||
|
||||
PersonSummaryDTO found = top.stream()
|
||||
.filter(p -> "Dokumentiert".equals(p.getLastName())).findFirst().orElseThrow();
|
||||
assertThat(found.getBirthDate()).isEqualTo(LocalDate.of(1910, 5, 1));
|
||||
assertThat(found.getBirthDatePrecision()).isEqualTo(DatePrecision.MONTH);
|
||||
assertThat(found.getDeathDate()).isNull();
|
||||
assertThat(found.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByFilter_exposesDateAndPrecisionFields() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Gefiltert").lastName("Genau")
|
||||
.deathDate(LocalDate.of(1944, 11, 2)).deathDatePrecision(DatePrecision.DAY)
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> found = personRepository.findByFilter(
|
||||
null, null, null, null, false, "Gefiltert", 10, 0);
|
||||
|
||||
assertThat(found).hasSize(1);
|
||||
assertThat(found.get(0).getBirthDate()).isNull();
|
||||
assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 2));
|
||||
assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAliasType;
|
||||
@@ -17,6 +19,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -241,27 +244,49 @@ class PersonServiceTest {
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria");
|
||||
dto.setBirthYear(1901); dto.setDeathYear(1975); dto.setNotes("Some notes");
|
||||
dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.DAY);
|
||||
dto.setDeathDate(LocalDate.of(1975, 11, 2)); dto.setDeathDatePrecision(DatePrecision.DAY);
|
||||
dto.setNotes("Some notes");
|
||||
|
||||
Person result = personService.createPerson(dto);
|
||||
|
||||
assertThat(result.getFirstName()).isEqualTo("Maria");
|
||||
assertThat(result.getLastName()).isEqualTo("Raddatz");
|
||||
assertThat(result.getAlias()).isEqualTo("Oma Maria");
|
||||
assertThat(result.getBirthYear()).isEqualTo(1901);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1975);
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1975, 11, 2));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(result.getNotes()).isEqualTo("Some notes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPerson_dto_yearValidationFires_whenBirthYearNegative() {
|
||||
void createPerson_dto_rejectsDateWithUnknownPrecision() {
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setBirthYear(-1);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Test");
|
||||
dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.UNKNOWN);
|
||||
|
||||
assertThatThrownBy(() -> personService.createPerson(dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> {
|
||||
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPerson_dto_treatsNullPrecisionWithNullDateAsUnknown() {
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.PERSON);
|
||||
|
||||
Person result = personService.createPerson(dto);
|
||||
|
||||
assertThat(result.getBirthDate()).isNull();
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
assertThat(result.getDeathDate()).isNull();
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -600,114 +625,135 @@ class PersonServiceTest {
|
||||
assertThat(result.getNotes()).isNull();
|
||||
}
|
||||
|
||||
// ─── updatePerson (birth/death years) ────────────────────────────────────
|
||||
// ─── updatePerson (birth/death dates) ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_persistsBirthAndDeathYear() {
|
||||
void updatePerson_persistsBirthAndDeathDateWithPrecision() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
|
||||
dto.setDeathDate(LocalDate.of(1965, 6, 12)); dto.setDeathDatePrecision(DatePrecision.DAY);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1890);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1965);
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 6, 12));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() {
|
||||
void updatePerson_throwsBirthAfterDeath_whenBirthDateAfterDeathDate() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1970, 5, 1)); dto.setBirthDatePrecision(DatePrecision.DAY);
|
||||
dto.setDeathDate(LocalDate.of(1950, 5, 1)); dto.setDeathDatePrecision(DatePrecision.DAY);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> {
|
||||
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
|
||||
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_doesNotThrow_whenBirthYearNonNullButDeathYearIsNull() {
|
||||
// Covers A && B short-circuit: birthYear != null (true) but deathYear == null (false) → no throw
|
||||
void updatePerson_throwsBirthAfterDeath_onMixedPrecisionLateBirthday() {
|
||||
// Known limitation (#773): DAY-precision birth late in the death's YEAR-precision year
|
||||
// compares against the year's backfilled Jan 1st and is rejected. The error message
|
||||
// carries the workaround hint via the BIRTH_AFTER_DEATH i18n key.
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1901, 11, 15)); dto.setBirthDatePrecision(DatePrecision.DAY);
|
||||
dto.setDeathDate(LocalDate.of(1901, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_doesNotThrow_whenBirthDateSetButDeathDateNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(null);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1890);
|
||||
assertThat(result.getDeathYear()).isNull();
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
|
||||
assertThat(result.getDeathDate()).isNull();
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_allowsSameYear() {
|
||||
void updatePerson_allowsEqualBirthAndDeathDate() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1900, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
|
||||
dto.setDeathDate(LocalDate.of(1900, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1900);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1900);
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||
}
|
||||
|
||||
// ─── Phase 1.3: Year range bounds (> 0) ──────────────────────────────────
|
||||
// ─── Date/precision coherence (V76 CHECK constraint mirror) ─────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenBirthYearIsZero() {
|
||||
void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionUnknown() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(0);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setDeathDate(LocalDate.of(1944, 11, 2)); dto.setDeathDatePrecision(DatePrecision.UNKNOWN);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> {
|
||||
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenBirthYearIsNegative() {
|
||||
void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(-5);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1901, 3, 14));
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenDeathYearIsZero() {
|
||||
void updatePerson_throwsInvalidDatePrecision_whenPrecisionSetWithoutDate() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(0);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDatePrecision(DatePrecision.DAY);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenDeathYearIsNegative() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(-10);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||
}
|
||||
|
||||
// ─── findCorrespondents ──────────────────────────────────────────────────
|
||||
|
||||
@@ -1,739 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentSort;
|
||||
import org.raddatz.familienarchiv.document.SearchFilters;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.NameMatches;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class NlQueryParserServiceTest {
|
||||
|
||||
@Mock OllamaClient ollamaClient;
|
||||
@Mock PersonService personService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock TagService tagService;
|
||||
|
||||
NlQueryParserService service;
|
||||
|
||||
static final Pageable PAGE = PageRequest.of(0, 20);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
service = new NlQueryParserService(ollamaClient, personService, documentService, tagService);
|
||||
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
when(documentService.searchDocumentsByPersonId(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
when(tagService.findByNameContaining(anyString())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
// --- Factory helpers ---
|
||||
|
||||
private OllamaExtraction extraction(List<String> names, String role, LocalDate from, LocalDate to,
|
||||
List<String> keywords) {
|
||||
String raw = names.isEmpty() ? "test query" : String.join(" ", names);
|
||||
return new OllamaExtraction(names, role, from, to, keywords, raw);
|
||||
}
|
||||
|
||||
private Person person(UUID id, String firstName, String lastName) {
|
||||
return Person.builder().id(id).firstName(firstName).lastName(lastName).build();
|
||||
}
|
||||
|
||||
private NameMatches makeNameMatches() {
|
||||
return new NameMatches(List.of(), List.of());
|
||||
}
|
||||
|
||||
private NameMatches makeNameMatches(List<Person> direct) {
|
||||
return new NameMatches(direct, List.of());
|
||||
}
|
||||
|
||||
private NameMatches makeNameMatches(List<Person> direct, List<Person> partial) {
|
||||
return new NameMatches(direct, partial);
|
||||
}
|
||||
|
||||
private static final UUID P1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
private static final UUID P2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
private static final UUID P3 = UUID.fromString("00000000-0000-0000-0000-000000000003");
|
||||
|
||||
// --- 1. Single resolved name + personRole=sender ---
|
||||
|
||||
@Test
|
||||
void search_resolvesSingleName_asSender() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
|
||||
NlSearchResponse resp = service.search("Was hat Walter geschrieben?", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
||||
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
||||
assertThat(cap.getValue().receiver()).isNull();
|
||||
assertThat(resp.interpretation().resolvedPersons()).hasSize(1);
|
||||
assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1);
|
||||
assertThat(resp.interpretation().ambiguousPersons()).isEmpty();
|
||||
}
|
||||
|
||||
// --- 2. Multi-match name → ambiguous, search NOT executed ---
|
||||
|
||||
@Test
|
||||
void search_multiMatchName_populatesAmbiguous_andSkipsSearch() {
|
||||
Person a = person(UUID.randomUUID(), "Walter", "Braun");
|
||||
Person b = person(UUID.randomUUID(), "Walter", "Schmidt");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(a, b)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||
assertThat(resp.interpretation().resolvedPersons()).isEmpty();
|
||||
}
|
||||
|
||||
// --- 3. Multi-match + personRole=any → still ambiguous, search NOT executed ---
|
||||
|
||||
@Test
|
||||
void search_multiMatchName_withPersonRoleAny_stillSkipsSearch() {
|
||||
Person a = person(UUID.randomUUID(), "Emma", "Braun");
|
||||
Person b = person(UUID.randomUUID(), "Emma", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Emma"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(a, b)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe an Emma", PAGE);
|
||||
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- 4. No-match name → folded into text ---
|
||||
|
||||
@Test
|
||||
void search_noMatchName_isFoldedIntoText() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Karl"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Karl")).thenReturn(makeNameMatches());
|
||||
|
||||
service.search("Briefe von Karl", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).contains("Karl");
|
||||
assertThat(cap.getValue().sender()).isNull();
|
||||
assertThat(cap.getValue().receiver()).isNull();
|
||||
}
|
||||
|
||||
// --- 5. personRole=any + 1 resolved → searchDocumentsByPersonId called ---
|
||||
|
||||
@Test
|
||||
void search_personRoleAny_singleMatch_callsSearchDocumentsByPersonId() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||
|
||||
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
assertThat(resp.interpretation().keywordsApplied()).isFalse();
|
||||
}
|
||||
|
||||
// --- 6. 2 names both resolve → sender=person1, receiver=person2 ---
|
||||
|
||||
@Test
|
||||
void search_twoNamesResolve_assignsSenderAndReceiver() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
Person emma = person(P2, "Emma", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
||||
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
||||
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
||||
assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1);
|
||||
assertThat(resp.interpretation().resolvedPersons().get(1).id()).isEqualTo(P2);
|
||||
}
|
||||
|
||||
// --- 7. 2 names, first resolves, second ambiguous → search NOT executed ---
|
||||
|
||||
@Test
|
||||
void search_twoNames_secondAmbiguous_skipsSearch() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
Person emma1 = person(P2, "Emma", "Braun");
|
||||
Person emma2 = person(P3, "Emma", "Schmidt");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma1, emma2)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
|
||||
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- 8. 2 names, first no match → folded into text, second used as single person ---
|
||||
|
||||
@Test
|
||||
void search_twoNames_firstNoMatch_secondResolved_foldFirstIntoText() {
|
||||
Person emma = person(P2, "Emma", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of()));
|
||||
when(personService.resolveByName("Karl")).thenReturn(makeNameMatches());
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
|
||||
service.search("Briefe von Karl an Emma", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).contains("Karl");
|
||||
assertThat(cap.getValue().sender()).isEqualTo(P2);
|
||||
}
|
||||
|
||||
// --- 9. 3+ names all resolve → first two as sender/receiver, third folded into text ---
|
||||
|
||||
@Test
|
||||
void search_threeNamesResolve_extraFoldedIntoText() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
Person emma = person(P2, "Emma", "Raddatz");
|
||||
Person heinrich = person(P3, "Heinrich", "Braun");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
when(personService.resolveByName("Heinrich")).thenReturn(makeNameMatches(List.of(heinrich)));
|
||||
|
||||
service.search("Briefe von Walter an Emma über Heinrich", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().sender()).isEqualTo(P1);
|
||||
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
||||
assertThat(cap.getValue().text()).contains("Heinrich");
|
||||
}
|
||||
|
||||
// --- 10. Keywords space-joined into text ---
|
||||
|
||||
@Test
|
||||
void search_keywords_areJoinedIntoText() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg", "Walter")));
|
||||
|
||||
service.search("Dokumente über den Krieg Walter", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).isEqualTo("Krieg Walter");
|
||||
}
|
||||
|
||||
// --- 11. Date range passed through ---
|
||||
|
||||
@Test
|
||||
void search_dateRange_passedIntoSearchFilters() {
|
||||
LocalDate from = LocalDate.of(1914, 1, 1);
|
||||
LocalDate to = LocalDate.of(1914, 12, 31);
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", from, to, List.of()));
|
||||
|
||||
service.search("Briefe aus dem Jahr 1914", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().from()).isEqualTo(from);
|
||||
assertThat(cap.getValue().to()).isEqualTo(to);
|
||||
}
|
||||
|
||||
// --- 12. Null dates → null in SearchFilters (not an error) ---
|
||||
|
||||
@Test
|
||||
void search_nullDates_passedAsNullIntoFilters() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
|
||||
service.search("Hochzeitsbriefe", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().from()).isNull();
|
||||
assertThat(cap.getValue().to()).isNull();
|
||||
}
|
||||
|
||||
// --- 13. Query under 3 chars → VALIDATION_ERROR before Ollama call ---
|
||||
|
||||
@Test
|
||||
void search_queryTooShort_throwsValidationError() {
|
||||
assertThatThrownBy(() -> service.search("ab", PAGE))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
|
||||
verify(ollamaClient, never()).parse(anyString());
|
||||
}
|
||||
|
||||
// --- 14. Query over 500 chars → VALIDATION_ERROR ---
|
||||
|
||||
@Test
|
||||
void search_queryTooLong_throwsValidationError() {
|
||||
String longQuery = "a".repeat(501);
|
||||
assertThatThrownBy(() -> service.search(longQuery, PAGE))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
|
||||
verify(ollamaClient, never()).parse(anyString());
|
||||
}
|
||||
|
||||
// --- 15. Ollama returns empty names/keywords → raw query used as keyword fallback ---
|
||||
|
||||
@Test
|
||||
void search_ollamaReturnsEmpty_usesRawQueryAsTextFallback() {
|
||||
String raw = "Briefe aus dem Krieg";
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of(), raw));
|
||||
|
||||
service.search(raw, PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).isEqualTo(raw);
|
||||
}
|
||||
|
||||
// --- 16. Null personNames/keywords from Ollama → no NPE ---
|
||||
|
||||
@Test
|
||||
void search_nullPersonNamesAndKeywords_handledWithoutNpe() {
|
||||
OllamaExtraction ext = new OllamaExtraction(null, "any", null, null, null, "test query");
|
||||
when(ollamaClient.parse(anyString())).thenReturn(ext);
|
||||
|
||||
NlSearchResponse resp = service.search("test query", PAGE);
|
||||
|
||||
assertThat(resp).isNotNull();
|
||||
verify(documentService).searchDocuments(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// --- 17. Unrecognized personRole → defaults to any-like behavior (no crash) ---
|
||||
|
||||
@Test
|
||||
void search_unrecognizedPersonRole_treatedLikeAny_withSingleResolvedPerson() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
// OllamaClient defensive parsing returns "any" for unknown roles,
|
||||
// but NlQueryParserService must also be safe if something unexpected arrives.
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(new OllamaExtraction(List.of("Walter"), "unknown_role", null, null, List.of(), "query"));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||
|
||||
// Should not crash; "unknown_role" treated as fallback (neither sender nor receiver → any)
|
||||
assertThat(resp).isNotNull();
|
||||
}
|
||||
|
||||
// --- 18. Ollama throws SMART_SEARCH_UNAVAILABLE → propagates to caller ---
|
||||
|
||||
@Test
|
||||
void search_ollamaThrowsUnavailable_propagates() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenThrow(DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, "offline"));
|
||||
|
||||
assertThatThrownBy(() -> service.search("Was hat Walter geschrieben?", PAGE))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// --- 19. LLM-extracted name > 200 chars → skipped, PersonService never called ---
|
||||
|
||||
@Test
|
||||
void search_nameLongerThan200Chars_isSkippedBeforePersonServiceCall() {
|
||||
String longName = "A".repeat(201);
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(longName), "sender", null, null, List.of()));
|
||||
|
||||
service.search("Briefe von sehr langem Namen", PAGE);
|
||||
|
||||
verify(personService, never()).resolveByName(anyString());
|
||||
}
|
||||
|
||||
// --- 20. Cap lives in resolveByName (after classification): a pre-capped 10-direct result
|
||||
// maps straight to ambiguousPersons; the search layer adds no second cap. ---
|
||||
|
||||
@Test
|
||||
void search_tenDirectMatches_allShownAsAmbiguous() {
|
||||
List<Person> ten = new ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) {
|
||||
ten.add(person(UUID.randomUUID(), "Walter", "Person" + i));
|
||||
}
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(ten));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(10);
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// --- 21. SearchFilters defaults: tagOperator=AND, status=null, undated=false, tags=empty ---
|
||||
|
||||
@Test
|
||||
void search_searchFiltersDefaults_areCorrect() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg")));
|
||||
|
||||
service.search("Dokumente über den Krieg", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
|
||||
SearchFilters f = cap.getValue();
|
||||
assertThat(f.tagOperator()).isEqualTo(TagOperator.AND);
|
||||
assertThat(f.status()).isNull();
|
||||
assertThat(f.undated()).isFalse();
|
||||
assertThat(f.tags()).isEmpty();
|
||||
assertThat(f.tagQ()).isNull();
|
||||
}
|
||||
|
||||
// --- 22. personRole=receiver + 1 resolved → receiver UUID set ---
|
||||
|
||||
@Test
|
||||
void search_personRoleReceiver_singleMatch_setsReceiver() {
|
||||
Person emma = person(P2, "Emma", "Raddatz");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Emma"), "receiver", null, null, List.of()));
|
||||
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
|
||||
|
||||
service.search("Briefe an Emma", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().receiver()).isEqualTo(P2);
|
||||
assertThat(cap.getValue().sender()).isNull();
|
||||
}
|
||||
|
||||
// --- 23. keywordsApplied=true when text is non-blank ---
|
||||
|
||||
@Test
|
||||
void search_keywordsApplied_trueWhenTextNonBlank() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
|
||||
|
||||
NlSearchResponse resp = service.search("Feldpost aus dem Krieg", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().keywordsApplied()).isTrue();
|
||||
}
|
||||
|
||||
// --- 23a. Partial-only, one candidate → ambiguous (1-item picker), search skipped ---
|
||||
|
||||
@Test
|
||||
void search_partialOnly_oneCandidate_populatesAmbiguous() {
|
||||
Person cramer = person(P1, "Clara", "Cramer");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(), List.of(cramer)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(1);
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
// --- 23b. Partial-only, two candidates → ambiguous (multi-item picker) ---
|
||||
|
||||
@Test
|
||||
void search_partialOnly_twoCandidates_populatesAmbiguous() {
|
||||
Person cramer = person(P1, "Clara", "Cramer");
|
||||
Person crammond = person(P2, "Clara", "Crammond");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Clara Cram"))
|
||||
.thenReturn(makeNameMatches(List.of(), List.of(cramer, crammond)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- 23c. Exactly one direct match → search executes, no picker ---
|
||||
|
||||
@Test
|
||||
void search_oneDirect_executesSearch() {
|
||||
Person clara = person(P1, "Clara", "Cram");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
|
||||
when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(clara)));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
|
||||
|
||||
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
|
||||
assertThat(resp.interpretation().ambiguousPersons()).isEmpty();
|
||||
}
|
||||
|
||||
// --- Tag resolution helpers ---
|
||||
|
||||
private Tag tag(UUID id, String name) {
|
||||
return Tag.builder().id(id).name(name).build();
|
||||
}
|
||||
|
||||
private Tag tag(UUID id, String name, String color) {
|
||||
return Tag.builder().id(id).name(name).color(color).build();
|
||||
}
|
||||
|
||||
private TagHint tagHint(UUID id, String name, String color) {
|
||||
return new TagHint(id, name, color);
|
||||
}
|
||||
|
||||
private static final UUID T1 = UUID.fromString("00000000-0000-0000-0001-000000000001");
|
||||
|
||||
// --- 24. Single keyword resolves to one tag → tag filter applied ---
|
||||
|
||||
@Test
|
||||
void search_singleKeywordResolvesToTag_appliesTagFilter() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe über Hochzeit", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
|
||||
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit");
|
||||
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
||||
assertThat(cap.getValue().text()).isNull();
|
||||
}
|
||||
|
||||
private static final UUID T2 = UUID.fromString("00000000-0000-0000-0001-000000000002");
|
||||
|
||||
// --- 25. Keyword matches multiple tags → all in resolvedTags, OR-union ---
|
||||
|
||||
@Test
|
||||
void search_keywordMatchesMultipleTags_allIncluded() {
|
||||
Tag hochzeit1 = tag(T1, "Hochzeit Raddatz");
|
||||
Tag hochzeit2 = tag(T2, "Hochzeit Braun");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit1, hochzeit2));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe über Hochzeit", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).containsExactlyInAnyOrder("Hochzeit Raddatz", "Hochzeit Braun");
|
||||
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- 26. Keyword no tag match → stays as FTS text, resolvedTags empty ---
|
||||
|
||||
@Test
|
||||
void search_keywordNoTagMatch_staysAsFtsText() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
|
||||
|
||||
NlSearchResponse resp = service.search("Feldpost Briefe", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).contains("Feldpost");
|
||||
assertThat(cap.getValue().tags()).isEmpty();
|
||||
assertThat(resp.interpretation().resolvedTags()).isEmpty();
|
||||
assertThat(resp.interpretation().tagsApplied()).isFalse();
|
||||
}
|
||||
|
||||
// --- 27. Mixed: one keyword resolves, one doesn't → tag filter + FTS text ---
|
||||
|
||||
@Test
|
||||
void search_mixedKeywords_oneResolves_oneStaysAsText() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "Feldpost")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit und Feldpost", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
|
||||
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
|
||||
assertThat(cap.getValue().text()).contains("Feldpost");
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
||||
}
|
||||
|
||||
// --- 28. personRole=any + 1 person + resolvable keyword → personId search, tagsApplied=false ---
|
||||
|
||||
@Test
|
||||
void search_personRoleAny_singlePerson_resolvableKeyword_tagsAppliedFalse() {
|
||||
Person walter = person(P1, "Walter", "Raddatz");
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of("Hochzeit")));
|
||||
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", PAGE);
|
||||
|
||||
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
|
||||
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
|
||||
assertThat(resp.interpretation().tagsApplied()).isFalse();
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit");
|
||||
}
|
||||
|
||||
// --- 29. Cap: keyword matches > 10 tags → capped at 10 ---
|
||||
|
||||
@Test
|
||||
void search_keywordMatchesMoreThanMaxTags_cappedAtTen() {
|
||||
List<Tag> eleven = new ArrayList<>();
|
||||
for (int i = 0; i < 11; i++) {
|
||||
eleven.add(tag(UUID.randomUUID(), "Thema " + i));
|
||||
}
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Thema")));
|
||||
when(tagService.findByNameContaining("Thema")).thenReturn(eleven);
|
||||
|
||||
NlSearchResponse resp = service.search("Dokumente zum Thema", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(10);
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).hasSize(10);
|
||||
}
|
||||
|
||||
// --- 30. Short keyword (< 3 chars) → skipped, not passed to TagService ---
|
||||
|
||||
@Test
|
||||
void search_shortKeyword_skippedByTagResolution() {
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("ab", "Krieg")));
|
||||
|
||||
service.search("ab Krieg", PAGE);
|
||||
|
||||
verify(tagService, never()).findByNameContaining("ab");
|
||||
verify(tagService).findByNameContaining("Krieg");
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).contains("ab");
|
||||
}
|
||||
|
||||
// --- 31. Dedup: same tag matched by two keywords → appears once ---
|
||||
|
||||
@Test
|
||||
void search_sameTagMatchedByTwoKeywords_deduplicatedToOne() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "hoch")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
when(tagService.findByNameContaining("hoch")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit hoch", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().tags()).hasSize(1);
|
||||
}
|
||||
|
||||
// --- 32. All keywords resolve → rawQuery fallback suppressed, text=null ---
|
||||
|
||||
@Test
|
||||
void search_allKeywordsResolveToTags_rawQueryFallbackSuppressed() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of("Hochzeit"), "raw query text"));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", PAGE);
|
||||
|
||||
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
|
||||
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
|
||||
assertThat(cap.getValue().text()).isNull();
|
||||
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
|
||||
}
|
||||
|
||||
// --- 33. Flag independence: keywordsApplied=false AND tagsApplied=true ---
|
||||
|
||||
@Test
|
||||
void search_allKeywordsResolveToTags_keywordsAppliedFalse_tagsAppliedTrue() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit Briefe", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().keywordsApplied()).isFalse();
|
||||
assertThat(resp.interpretation().tagsApplied()).isTrue();
|
||||
}
|
||||
|
||||
// --- 34. Color carried through from resolveEffectiveColors ---
|
||||
|
||||
@Test
|
||||
void search_tagHint_carriesColorSetByResolveEffectiveColors() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
doAnswer(invocation -> {
|
||||
Collection<Tag> tags = invocation.getArgument(0);
|
||||
tags.forEach(t -> t.setColor("sage"));
|
||||
return null;
|
||||
}).when(tagService).resolveEffectiveColors(any());
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).color()).isEqualTo("sage");
|
||||
}
|
||||
|
||||
// --- 35. Color stays null when resolveEffectiveColors leaves it unset ---
|
||||
|
||||
@Test
|
||||
void search_tagHint_colorIsNull_whenNoColorResolved() {
|
||||
Tag hochzeit = tag(T1, "Hochzeit");
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
|
||||
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
|
||||
|
||||
NlSearchResponse resp = service.search("Hochzeit", PAGE);
|
||||
|
||||
assertThat(resp.interpretation().resolvedTags().get(0).color()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.document.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@WebMvcTest(NlSearchController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class,
|
||||
NlSearchRateLimiter.class, NlSearchRateLimitProperties.class})
|
||||
class NlSearchControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@MockitoBean NlQueryParserService nlQueryParserService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
@Autowired NlSearchRateLimiter rateLimiter;
|
||||
|
||||
@BeforeEach
|
||||
void resetRateLimiter() {
|
||||
rateLimiter.resetForTest();
|
||||
}
|
||||
|
||||
private NlSearchResponse makeResponse() {
|
||||
PersonHint hint = new PersonHint(UUID.randomUUID(), "Walter Raddatz");
|
||||
NlQueryInterpretation interp = new NlQueryInterpretation(
|
||||
List.of(hint), List.of(), null, null,
|
||||
List.of("Krieg"), List.of(), "Briefe von Walter im Krieg", true, false);
|
||||
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
|
||||
}
|
||||
|
||||
// --- 1. Happy path ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns200_withNlSearchResponse() throws Exception {
|
||||
when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse());
|
||||
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter im Krieg\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.interpretation.rawQuery").value("Briefe von Walter im Krieg"))
|
||||
.andExpect(jsonPath("$.interpretation.resolvedPersons[0].displayName").value("Walter Raddatz"))
|
||||
.andExpect(jsonPath("$.interpretation.keywordsApplied").value(true));
|
||||
}
|
||||
|
||||
// --- 2. ambiguousPersons in response shape ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns200_withAmbiguousPersons() throws Exception {
|
||||
PersonHint a = new PersonHint(UUID.randomUUID(), "Walter Braun");
|
||||
PersonHint b = new PersonHint(UUID.randomUUID(), "Walter Schmidt");
|
||||
NlQueryInterpretation interp = new NlQueryInterpretation(
|
||||
List.of(), List.of(a, b), null, null,
|
||||
List.of(), List.of(), "Briefe von Walter", false, false);
|
||||
NlSearchResponse resp = new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
|
||||
when(nlQueryParserService.search(anyString(), any())).thenReturn(resp);
|
||||
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.interpretation.ambiguousPersons").isArray())
|
||||
.andExpect(jsonPath("$.interpretation.ambiguousPersons[0].displayName").value("Walter Braun"))
|
||||
.andExpect(jsonPath("$.interpretation.ambiguousPersons[1].id").isNotEmpty());
|
||||
}
|
||||
|
||||
// --- 3. Unauthenticated → 401 ---
|
||||
|
||||
@Test
|
||||
void search_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
// --- 4. Query < 3 chars → 400 ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns400_whenQueryTooShort() throws Exception {
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"ab\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// --- 5. Query > 500 chars → 400 ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns400_whenQueryTooLong() throws Exception {
|
||||
String longQuery = "a".repeat(501);
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"" + longQuery + "\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
// --- 6. Ollama unavailable → 503 ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns503_whenOllamaUnavailable() throws Exception {
|
||||
when(nlQueryParserService.search(anyString(), any()))
|
||||
.thenThrow(DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, "Ollama offline"));
|
||||
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter\"}"))
|
||||
.andExpect(status().isServiceUnavailable())
|
||||
.andExpect(jsonPath("$.code").value("SMART_SEARCH_UNAVAILABLE"));
|
||||
}
|
||||
|
||||
// --- 7. 6th request in 1 minute → 429 ---
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
|
||||
void search_returns429_onSixthRequestWithinRateLimit() throws Exception {
|
||||
when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse());
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
mockMvc.perform(post("/api/search/nl").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"query\":\"Briefe von Walter\"}"))
|
||||
.andExpect(status().isTooManyRequests())
|
||||
.andExpect(jsonPath("$.code").value("SMART_SEARCH_RATE_LIMITED"));
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
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.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class NlSearchRateLimiterTest {
|
||||
|
||||
private NlSearchRateLimiter rateLimiter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
NlSearchRateLimitProperties props = new NlSearchRateLimitProperties();
|
||||
props.setMaxRequestsPerMinute(5);
|
||||
rateLimiter = new NlSearchRateLimiter(props);
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndConsume_allowsRequestsWithinLimit() {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndConsume_throwsRateLimited_onSixthRequest() {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
rateLimiter.checkAndConsume("user@example.com");
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> rateLimiter.checkAndConsume("user@example.com"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.SMART_SEARCH_RATE_LIMITED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void checkAndConsume_limitsAreIndependentPerUser() {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
rateLimiter.checkAndConsume("alice@example.com");
|
||||
}
|
||||
assertThatCode(() -> rateLimiter.checkAndConsume("bob@example.com"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetForTest_clearsAllBuckets() {
|
||||
for (int i = 0; i < 5; i++) {
|
||||
rateLimiter.checkAndConsume("user@example.com");
|
||||
}
|
||||
|
||||
rateLimiter.resetForTest();
|
||||
|
||||
assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com"))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.tag.TagRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class NlSearchTagResolutionIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private TagRepository tagRepository;
|
||||
|
||||
@Test
|
||||
void findDescendantIdsByName_parentName_includesChildId() {
|
||||
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
|
||||
Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
|
||||
|
||||
List<UUID> ids = tagRepository.findDescendantIdsByName("Krieg");
|
||||
|
||||
assertThat(ids).containsExactlyInAnyOrder(parent.getId(), child.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findDescendantIdsByName_childName_returnsOnlyChild() {
|
||||
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
|
||||
Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
|
||||
|
||||
List<UUID> ids = tagRepository.findDescendantIdsByName("Weltkrieg");
|
||||
|
||||
assertThat(ids).containsExactly(child.getId());
|
||||
assertThat(ids).doesNotContain(parent.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByNameContainingIgnoreCase_parentSubstring_matchesParentOnly() {
|
||||
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
|
||||
tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
|
||||
|
||||
List<Tag> found = tagRepository.findByNameContainingIgnoreCase("Krieg");
|
||||
|
||||
assertThat(found).extracting(Tag::getName).containsExactlyInAnyOrder("Krieg", "Weltkrieg");
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
import com.github.tomakehurst.wiremock.WireMockServer;
|
||||
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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 com.github.tomakehurst.wiremock.client.WireMock.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class RestClientOllamaClientTest {
|
||||
|
||||
private WireMockServer wireMock;
|
||||
private RestClientOllamaClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
wireMock = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
|
||||
wireMock.start();
|
||||
|
||||
OllamaProperties props = new OllamaProperties();
|
||||
props.setBaseUrl("http://localhost:" + wireMock.port());
|
||||
props.setModel("qwen2.5:7b-instruct-q4_K_M");
|
||||
props.setTimeoutSeconds(5);
|
||||
props.setHealthCheckTimeoutSeconds(2);
|
||||
|
||||
client = new RestClientOllamaClient(props);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
wireMock.stop();
|
||||
}
|
||||
|
||||
// --- Factory helpers ---
|
||||
|
||||
private String makeOllamaResponseJson(String personNamesJson, String personRole,
|
||||
String dateFrom, String dateTo, String keywordsJson) {
|
||||
String inner = String.format(
|
||||
"{\"personNames\":%s,\"personRole\":\"%s\",\"dateFrom\":%s,\"dateTo\":%s,\"keywords\":%s}",
|
||||
personNamesJson, personRole,
|
||||
dateFrom == null ? "null" : "\"" + dateFrom + "\"",
|
||||
dateTo == null ? "null" : "\"" + dateTo + "\"",
|
||||
keywordsJson
|
||||
);
|
||||
return String.format("{\"model\":\"qwen2.5:7b-instruct-q4_K_M\",\"response\":\"%s\",\"done\":true}",
|
||||
inner.replace("\"", "\\\""));
|
||||
}
|
||||
|
||||
// --- Test cases ---
|
||||
|
||||
@Test
|
||||
void parse_returnsExtraction_whenOllamaReturnsValidJson() {
|
||||
String body = makeOllamaResponseJson("[\"Walter\"]", "sender", "1914-01-01", "1914-12-31", "[\"Krieg\"]");
|
||||
wireMock.stubFor(post(urlEqualTo("/api/generate"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody(body)));
|
||||
|
||||
OllamaExtraction result = client.parse("Was hat Walter im Krieg geschrieben?");
|
||||
|
||||
assertThat(result.personNames()).containsExactly("Walter");
|
||||
assertThat(result.personRole()).isEqualTo("sender");
|
||||
assertThat(result.keywords()).containsExactly("Krieg");
|
||||
assertThat(result.dateFrom()).isNotNull();
|
||||
assertThat(result.dateTo()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_throwsSmartSearchUnavailable_whenOllamaReturns500() {
|
||||
wireMock.stubFor(post(urlEqualTo("/api/generate"))
|
||||
.willReturn(aResponse().withStatus(500)));
|
||||
|
||||
assertThatThrownBy(() -> client.parse("some query"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_throwsSmartSearchUnavailable_whenOllamaExceedsTimeout() {
|
||||
wireMock.stubFor(post(urlEqualTo("/api/generate"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withFixedDelay(6000)
|
||||
.withBody("{\"response\":\"{}\",\"done\":true}")));
|
||||
|
||||
assertThatThrownBy(() -> client.parse("some query"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parse_throwsSmartSearchUnavailable_whenOllamaReturnsMalformedJson() {
|
||||
wireMock.stubFor(post(urlEqualTo("/api/generate"))
|
||||
.willReturn(aResponse()
|
||||
.withStatus(200)
|
||||
.withHeader("Content-Type", "application/json")
|
||||
.withBody("{\"response\":\"not-json-at-all\",\"done\":true}")));
|
||||
|
||||
assertThatThrownBy(() -> client.parse("some query"))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,8 @@ class ArchitectureTest {
|
||||
.that().areAnnotatedWith(Entity.class)
|
||||
.should().resideInAnyPackage(
|
||||
"..document..", "..person..", "..tag..", "..user..",
|
||||
"..geschichte..", "..notification..", "..ocr..", "..audit.."
|
||||
"..geschichte..", "..notification..", "..ocr..", "..audit..",
|
||||
"..timeline.."
|
||||
);
|
||||
|
||||
// TODO Rule 5: Controllers expose endpoints under their domain prefix
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
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.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Proves V77's FK {@code ON DELETE CASCADE} on the join tables: deleting a linked Person or
|
||||
* Document drops the join row and leaves the {@link TimelineEvent} intact (a person/document
|
||||
* delete must never 500 — V71-class regression guard). Needs the full Spring context for
|
||||
* {@link PersonService}/{@link DocumentService}, mirroring {@code PersonServiceIntegrationTest}.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class TimelineEventCascadeIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired TimelineEventRepository events;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired PersonService personService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired JdbcTemplate jdbc;
|
||||
@PersistenceContext EntityManager em;
|
||||
|
||||
private TimelineEvent.TimelineEventBuilder makeEvent() {
|
||||
return TimelineEvent.builder()
|
||||
.title("Hochzeit von Anna und Otto")
|
||||
.type(EventType.PERSONAL)
|
||||
.eventDate(LocalDate.of(1914, 7, 28))
|
||||
.createdBy(UUID.randomUUID())
|
||||
.updatedBy(UUID.randomUUID());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleting_linked_person_keeps_event_and_drops_join_row() {
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
|
||||
TimelineEvent event = events.save(makeEvent().persons(Set.of(anna)).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
personService.deletePerson(anna.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(event.getId())).isPresent();
|
||||
Integer joinRows = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM timeline_event_persons WHERE timeline_event_id = ?",
|
||||
Integer.class, event.getId());
|
||||
assertThat(joinRows).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleting_linked_document_keeps_event_and_drops_join_row() {
|
||||
Document letter = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
TimelineEvent event = events.save(makeEvent().documents(Set.of(letter)).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(letter.getId(), UUID.randomUUID());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(event.getId())).isPresent();
|
||||
Integer joinRows = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM timeline_event_documents WHERE timeline_event_id = ?",
|
||||
Integer.class, event.getId());
|
||||
assertThat(joinRows).isZero();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
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.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.dao.DataIntegrityViolationException;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Persistence + DB-constraint tests for {@link TimelineEvent} against real Postgres (V77).
|
||||
* Mirrors {@code MigrationIntegrationTest}'s slice setup; never H2 — only the real DB proves
|
||||
* enum-as-varchar storage, the RANGE/UNKNOWN CHECK constraints, FK cascade, and {@code @Version}.
|
||||
*/
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class TimelineEventTest {
|
||||
|
||||
@Autowired TimelineEventRepository events;
|
||||
@Autowired PersonRepository persons;
|
||||
@Autowired DocumentRepository documents;
|
||||
@Autowired EntityManager em;
|
||||
|
||||
/**
|
||||
* Sensible defaults; each test overrides only what it asserts. {@code createdBy}/{@code updatedBy}
|
||||
* default to random UUIDs — both columns are NOT NULL and not auto-populated, so without these
|
||||
* every test would fail at flush with the same constraint violation (red for the wrong reason).
|
||||
* Precision is intentionally left unset so {@code @Builder.Default YEAR} can be exercised.
|
||||
*/
|
||||
private TimelineEvent.TimelineEventBuilder makeEvent() {
|
||||
return TimelineEvent.builder()
|
||||
.title("Hochzeit von Anna und Otto")
|
||||
.type(EventType.PERSONAL)
|
||||
.eventDate(LocalDate.of(1914, 7, 28))
|
||||
.createdBy(UUID.randomUUID())
|
||||
.updatedBy(UUID.randomUUID());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persists_and_loads_event_with_required_fields() {
|
||||
TimelineEvent saved = events.save(makeEvent().build());
|
||||
|
||||
assertThat(saved.getId()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void precision_defaults_to_YEAR_when_not_set() {
|
||||
TimelineEvent saved = events.save(makeEvent().build());
|
||||
|
||||
assertThat(saved.getPrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void persists_event_with_linked_persons() {
|
||||
Person anna = persons.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
|
||||
TimelineEvent saved = events.save(makeEvent().persons(Set.of(anna)).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
|
||||
assertThat(reloaded.getPersons()).extracting(Person::getId).containsExactly(anna.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persists_event_with_linked_documents() {
|
||||
Document letter = documents.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
TimelineEvent saved = events.save(makeEvent().documents(Set.of(letter)).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
|
||||
assertThat(reloaded.getDocuments()).extracting(Document::getId).containsExactly(letter.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventDateEnd_round_trips_null_for_non_range() {
|
||||
TimelineEvent saved = events.save(makeEvent().build()); // YEAR precision, no end
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventDateEnd_round_trips_value_for_range() {
|
||||
TimelineEvent saved = events.save(makeEvent()
|
||||
.precision(DatePrecision.RANGE)
|
||||
.eventDate(LocalDate.of(1914, 1, 1))
|
||||
.eventDateEnd(LocalDate.of(1918, 12, 31))
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd())
|
||||
.isEqualTo(LocalDate.of(1918, 12, 31));
|
||||
}
|
||||
|
||||
@Test
|
||||
void description_round_trips_null() {
|
||||
TimelineEvent saved = events.save(makeEvent().build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void description_round_trips_multi_kb_text() {
|
||||
// Proves TEXT has no length cap — @Column(columnDefinition = "TEXT") overrides
|
||||
// Hibernate's default VARCHAR(255).
|
||||
String longText = "Sommertage am See. ".repeat(500); // ~9.5 KB
|
||||
TimelineEvent saved = events.save(makeEvent().description(longText).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isEqualTo(longText);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
void range_invariant_rejects_non_null_end_without_range_precision() {
|
||||
// precision YEAR + non-null end violates chk_timeline_event_range.
|
||||
try {
|
||||
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
|
||||
.eventDateEnd(LocalDate.of(1918, 12, 31))
|
||||
.build()))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
} finally {
|
||||
events.deleteAll(); // NOT_SUPPORTED opts out of the rollback; clean any leaked row
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
void range_invariant_rejects_range_precision_without_end_date() {
|
||||
// precision RANGE + null end violates chk_timeline_event_range.
|
||||
try {
|
||||
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
|
||||
.precision(DatePrecision.RANGE)
|
||||
.build()))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
} finally {
|
||||
events.deleteAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
void unknown_precision_is_rejected() {
|
||||
// chk_timeline_event_precision forbids UNKNOWN — curated events are never undated.
|
||||
try {
|
||||
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
|
||||
.precision(DatePrecision.UNKNOWN)
|
||||
.build()))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
} finally {
|
||||
events.deleteAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void version_is_null_before_persist_and_zero_after_save() {
|
||||
TimelineEvent fresh = makeEvent().build();
|
||||
assertThat(fresh.getVersion()).isNull(); // @Version Long is null pre-persist
|
||||
|
||||
TimelineEvent saved = events.saveAndFlush(fresh);
|
||||
assertThat(saved.getVersion()).isEqualTo(0L); // Hibernate sets 0 on insert
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = DatePrecision.class, names = {"DAY", "MONTH", "SEASON", "YEAR", "APPROX"})
|
||||
void all_non_unknown_precisions_are_accepted(DatePrecision precision) {
|
||||
// Accept-side of chk_timeline_event_precision: every non-RANGE, non-UNKNOWN value persists.
|
||||
// Documents that SEASON ("Sommer 1914") and APPROX ("ca. 1914") are intentionally legal,
|
||||
// so an over-tight CHECK cannot ship green. (RANGE is covered by the round-trip test.)
|
||||
TimelineEvent saved = events.saveAndFlush(makeEvent().precision(precision).build());
|
||||
|
||||
assertThat(saved.getId()).isNotNull();
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,6 @@ volumes:
|
||||
minio-data:
|
||||
ocr-models:
|
||||
ocr-cache:
|
||||
ollama-models:
|
||||
|
||||
services:
|
||||
db:
|
||||
@@ -201,73 +200,6 @@ services:
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
# --- Ollama: Model init (one-shot pull) ---
|
||||
# Pulls qwen2.5:7b-instruct-q4_K_M (~4.7 GB) into the ollama-models volume on
|
||||
# first start; exits quickly on subsequent starts (model already cached).
|
||||
# The ollama/ollama image's ENTRYPOINT is `ollama` and the image ships WITHOUT
|
||||
# curl, so the entrypoint is overridden to a shell and readiness is probed with
|
||||
# `ollama list` (not curl). The pull is guarded by a `grep` on the cached model
|
||||
# list so a model already on the volume exits clean WITHOUT a registry round-trip
|
||||
# — a host reboot during a registry/network blip can no longer fail init (which
|
||||
# would block the ollama service via service_completed_successfully).
|
||||
# Backend degrades gracefully (503) if Ollama is absent.
|
||||
ollama-model-init:
|
||||
image: ollama/ollama:0.30.6
|
||||
restart: "no"
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && (ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)"
|
||||
networks:
|
||||
- archiv-net
|
||||
volumes:
|
||||
- ollama-models:/root/.ollama
|
||||
mem_limit: 2g
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=512m
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
# --- Ollama: LLM inference server ---
|
||||
# Serves the pre-pulled model for NL search inference. Backend reaches it at
|
||||
# http://ollama:11434 (application.yaml default; no env override required).
|
||||
# Healthcheck uses `ollama list` because the image has no curl.
|
||||
ollama:
|
||||
image: ollama/ollama:0.30.6
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "11434"
|
||||
networks:
|
||||
- archiv-net
|
||||
volumes:
|
||||
- ollama-models:/root/.ollama
|
||||
environment:
|
||||
# Pin the model in memory (no idle unload). Without this, Ollama evicts
|
||||
# the model after ~5 min idle and the next query pays a cold-load penalty
|
||||
# that exceeds the backend read timeout → NL search 503 after idle.
|
||||
OLLAMA_KEEP_ALIVE: "-1"
|
||||
cpus: "${OLLAMA_CPU_LIMIT:-4.0}"
|
||||
mem_limit: "${OLLAMA_MEM_LIMIT:-8g}"
|
||||
memswap_limit: "${OLLAMA_MEM_LIMIT:-8g}"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=512m
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test: ["CMD", "ollama", "list"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
depends_on:
|
||||
ollama-model-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
backend:
|
||||
image: familienarchiv/backend:${TAG:-nightly}
|
||||
build:
|
||||
|
||||
@@ -141,75 +141,6 @@ services:
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
# --- Ollama: Model init (one-shot pull) ---
|
||||
# Pulls qwen2.5:7b-instruct-q4_K_M (~4.7 GB) into the ollama_models volume on first start.
|
||||
# On subsequent starts (model already in volume), exits quickly without re-downloading.
|
||||
# Not started in CI — CI uses explicit service selection
|
||||
# (docker-compose.ci.yml: db minio create-buckets)
|
||||
ollama-model-init:
|
||||
image: ollama/ollama:0.30.6
|
||||
restart: "no"
|
||||
networks:
|
||||
- archiv-net
|
||||
volumes:
|
||||
- ollama_models:/root/.ollama
|
||||
mem_limit: 2g
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=512m
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
# The image ENTRYPOINT is `ollama`, so override it to a shell; the image has
|
||||
# no curl, so readiness is probed with `ollama list` instead of a curl loop.
|
||||
# The pull is guarded by a `grep` on the cached model list so an already-cached
|
||||
# model exits clean without a registry round-trip (offline-safe re-up).
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && (ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)"
|
||||
|
||||
# --- Ollama: LLM inference server ---
|
||||
# Serves the pre-pulled model for NL search inference.
|
||||
# Not started in CI — CI uses explicit service selection
|
||||
# (docker-compose.ci.yml: db minio create-buckets)
|
||||
ollama:
|
||||
image: ollama/ollama:0.30.6
|
||||
container_name: archive-ollama
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "11434"
|
||||
networks:
|
||||
- archiv-net
|
||||
volumes:
|
||||
- ollama_models:/root/.ollama
|
||||
environment:
|
||||
OLLAMA_API_KEY: "${OLLAMA_API_KEY}"
|
||||
# Pin the model in memory (no idle unload) so queries never pay a cold-load
|
||||
# penalty that exceeds the backend read timeout → NL search 503 after idle.
|
||||
OLLAMA_KEEP_ALIVE: "-1"
|
||||
cpus: "${OLLAMA_CPU_LIMIT:-4.0}"
|
||||
mem_limit: "${OLLAMA_MEM_LIMIT:-8g}"
|
||||
memswap_limit: "${OLLAMA_MEM_LIMIT:-8g}"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=512m
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
# `ollama list` hits the local API and exits non-zero if the server is
|
||||
# down — used instead of curl, which the image does not ship.
|
||||
test: ["CMD", "ollama", "list"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s # model weights are pre-loaded by ollama-model-init; service only needs to bind port
|
||||
depends_on:
|
||||
ollama-model-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
# --- Backend: Spring Boot ---
|
||||
backend:
|
||||
build:
|
||||
@@ -253,8 +184,6 @@ services:
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
|
||||
APP_OCR_BASE_URL: http://ocr-service:8000
|
||||
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
|
||||
APP_OLLAMA_BASE_URL: "${APP_OLLAMA_BASE_URL:-http://ollama:11434}"
|
||||
APP_OLLAMA_API_KEY: "${OLLAMA_API_KEY}"
|
||||
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0}
|
||||
# Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317)
|
||||
@@ -318,4 +247,3 @@ volumes:
|
||||
frontend_node_modules:
|
||||
ocr_models:
|
||||
ocr_cache:
|
||||
ollama_models:
|
||||
|
||||
@@ -38,7 +38,7 @@ Both stacks are organised **package-by-domain**: each domain owns its entities,
|
||||
|
||||
**`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events).
|
||||
|
||||
**`geschichte`** — family stories. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle). Cross-domain deps: `person`, `document` (linked entities in the story body).
|
||||
**`geschichte`** — family stories and Lesereisen. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle) and `JourneyItem` (document attachments / editorial notes shared by both subtypes — no application-level type guard). Two subtypes: `STORY` (prose + attached documents) and `JOURNEY` (ordered curated sequence). Cross-domain deps: `person` (linked persons), `document` (via `JourneyItem.document_id`, ON DELETE SET NULL). See ADR-037.
|
||||
|
||||
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).
|
||||
|
||||
@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. |
|
||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
|
||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||
|
||||
@@ -50,17 +50,15 @@ graph TD
|
||||
|
||||
The OCR service requires significant RAM for model loading. The dev compose sets `mem_limit: 12g`.
|
||||
|
||||
| Production target | RAM | Recommended OCR limit | NL Search | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Supported | Default `mem_limit: 12g` works comfortably; plenty of headroom for Ollama |
|
||||
| ≥ 16 GB RAM | 16+ GB | 12 GB | Supported | Default works |
|
||||
| 8 GB RAM | 8 GB | 6 GB | Disabled — set `APP_OLLAMA_BASE_URL=` (empty) | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
|
||||
| 4 GB RAM | 4 GB | — | Unsupported | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
|
||||
| Production target | RAM | Recommended OCR limit | Notes |
|
||||
|---|---|---|---|
|
||||
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Default `mem_limit: 12g` works comfortably |
|
||||
| ≥ 16 GB RAM | 16+ GB | 12 GB | Default works |
|
||||
| 8 GB RAM | 8 GB | 6 GB | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
|
||||
| 4 GB RAM | 4 GB | — | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
|
||||
|
||||
On servers with less than 16 GB RAM the default `mem_limit: 12g` cannot be honoured — set the `OCR_MEM_LIMIT` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow). The prod compose interpolates this var with a 12g default.
|
||||
|
||||
> **Memory budget:** OCR (~6 GB active) + Ollama (~8 GB) = ~14 GB. On servers with less than 16 GB RAM, do not run `docker-compose.observability.yml` continuously alongside both OCR and Ollama.
|
||||
|
||||
### Dev vs production differences
|
||||
|
||||
| Concern | Dev (`docker-compose.yml`) | Prod (`docker-compose.prod.yml`) |
|
||||
@@ -126,6 +124,8 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `POSTGRES_PASSWORD` | DB password | `change-me` | YES | YES |
|
||||
| `POSTGRES_DB` | Database name | `family_archive_db` | YES | — |
|
||||
|
||||
> **PgBouncer pooling mode:** The `journey_items.position_seq` dedup constraint uses `DEFERRABLE INITIALLY DEFERRED`. This requires PgBouncer in **transaction-mode** (not statement-mode) pooling. Do not switch to statement-level pooling — deferred constraints only work within a single transaction session.
|
||||
|
||||
### MinIO container
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
@@ -147,16 +147,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `XDG_CACHE_HOME` | XDG cache base dir — redirects Matplotlib and other XDG-aware libraries away from the read-only `HOME` (`/home/ocr`) to the writable cache volume | `/app/cache` | — | — |
|
||||
| `TORCH_HOME` | PyTorch model cache — redirects `~/.cache/torch` to the writable models volume | `/app/models/torch` | — | — |
|
||||
|
||||
### Ollama (NL search) service
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
|---|---|---|---|---|
|
||||
| `APP_OLLAMA_BASE_URL` | Base URL for the Ollama service. Leave empty to disable NL search. | `http://ollama:11434` | — | — |
|
||||
| `APP_OLLAMA_API_KEY` | API key passed as `Authorization: Bearer` to Ollama. Leave empty for unauthenticated access. Note: `OLLAMA_API_KEY` is not enforced in Ollama 0.6.5 or 0.30.6 (see ADR-028). | — | — | YES |
|
||||
| `OLLAMA_CPU_LIMIT` | Docker CPU quota for the Ollama container. On CX42 (8 vCPUs) can be raised to `7.5`. | `4.0` | — | — |
|
||||
| `OLLAMA_MEM_LIMIT` | Memory limit for the Ollama container. Requires CX42 (16 GB RAM). | `8g` | — | — |
|
||||
| `OLLAMA_API_KEY` | API key set on the Ollama service itself. Same value as `APP_OLLAMA_API_KEY`. Leave empty for unauthenticated. | — | — | YES |
|
||||
|
||||
### Observability stack (`docker-compose.observability.yml`)
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
@@ -277,18 +267,6 @@ git.raddatz.cloud A <server IP>
|
||||
|
||||
### 3.4 First deploy
|
||||
|
||||
> **First start — Ollama model pull:** On first `docker compose up -d`, the `ollama-model-init` container pulls `qwen2.5:7b-instruct-q4_K_M` (~4.7 GB). At 10 Mbps this takes approximately 60–90 minutes; at 100 Mbps approximately 6–10 minutes. The pull is a one-time operation — subsequent restarts skip it (model already on the `ollama_models` volume). Monitor progress with `docker logs -f $(docker ps -q --filter name=ollama-model-init)`.
|
||||
>
|
||||
> **Do not use `--wait` on first deploy** — `docker compose up -d --wait` waits for all services to reach their health/completion target, including `ollama-model-init`. On first pull this blocks for 60–90 minutes and will time out any CI/deploy script that uses `--wait`.
|
||||
>
|
||||
> **Re-deploy idempotency:** on subsequent `docker compose up -d` runs (including `--force-recreate`), `ollama-model-init` re-executes but exits in seconds — Ollama's CLI skips the download when the model digest already matches what is on the volume.
|
||||
>
|
||||
> **Verify NL search is active** after enabling Ollama (`APP_OLLAMA_BASE_URL=http://ollama:11434`):
|
||||
> ```bash
|
||||
> curl -s http://localhost:8080/api/nl-search?q=brief+von+grossmutter
|
||||
> # Returns 200 with results → NL search is active
|
||||
> # Returns 503 NL_SEARCH_UNAVAILABLE → Ollama is not reachable or APP_OLLAMA_BASE_URL is unset
|
||||
> ```
|
||||
|
||||
```bash
|
||||
# 1. Trigger nightly.yml manually (Repo → Actions → nightly → "Run workflow")
|
||||
@@ -540,6 +518,26 @@ docker exec -i archive-db psql -U ${POSTGRES_USER} ${POSTGRES_DB} < backup-YYYYM
|
||||
|
||||
Automated backup (nightly `pg_dump` + MinIO `mc mirror` over Tailscale to `heim-nas`) is a follow-up issue. Until that ships: **manual backups are the only recovery option.**
|
||||
|
||||
### Deploy note — V76 (persons birth/death → date + precision, #773)
|
||||
|
||||
V76 drops `persons.birth_year`/`death_year` after backfilling the new
|
||||
`birth_date`/`death_date` + precision columns — a **one-way migration** (Flyway cannot
|
||||
roll it back). Before deploying:
|
||||
|
||||
1. Take a manual `pg_dump` (see above) — there is no automated nightly backup yet, so
|
||||
confirm the dump completed before starting the deploy.
|
||||
2. No maintenance window is required: the pre-check + DDL run in one atomic Flyway
|
||||
transaction, and this single-writer archive has no concurrent importers during deploy.
|
||||
|
||||
If post-deploy data issues are found, restore **only the persons table** from the
|
||||
pre-migration dump (targeted restore, not a full-database restore):
|
||||
|
||||
```bash
|
||||
pg_restore -t persons -d ${POSTGRES_DB} backup-YYYYMMDD.dump
|
||||
```
|
||||
|
||||
(For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.)
|
||||
|
||||
### Rollback
|
||||
|
||||
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:
|
||||
@@ -585,55 +583,6 @@ bash scripts/download-kraken-models.sh
|
||||
|
||||
> Downloads the Kurrent/Sütterlin HTR models. Run once after a fresh clone or when models are updated.
|
||||
|
||||
### Ollama — natural-language search (NL Search)
|
||||
|
||||
NL search uses a local Ollama instance for query parsing. The `ollama` service is defined in `docker-compose.yml` alongside the main stack.
|
||||
|
||||
**First-time model pull** (required before the feature works):
|
||||
|
||||
```bash
|
||||
docker compose exec ollama ollama pull qwen2.5:7b-instruct-q4_K_M
|
||||
```
|
||||
|
||||
This downloads ~4.4 GB. The model is stored in the `ollama_data` Docker volume and persists across container restarts.
|
||||
|
||||
**Verify the model is available:**
|
||||
|
||||
```bash
|
||||
docker compose exec ollama ollama list
|
||||
```
|
||||
|
||||
Expected output includes `qwen2.5:7b-instruct-q4_K_M`.
|
||||
|
||||
**Health check** — the backend polls `GET /api/tags` on Ollama at startup and before inference. If Ollama is absent, `POST /api/search/nl` returns HTTP 503 with `SMART_SEARCH_UNAVAILABLE`.
|
||||
|
||||
**Configuration** (see `application.yaml` under `app.ollama`):
|
||||
|
||||
| Property | Default | Description |
|
||||
|---|---|---|
|
||||
| `app.ollama.base-url` | `http://ollama:11434` | Ollama service URL (dev: `http://localhost:11434`) |
|
||||
| `app.ollama.model` | `qwen2.5:7b-instruct-q4_K_M` | Model to use for inference |
|
||||
| `app.ollama.timeout-seconds` | `60` | Read timeout for inference calls (absorbs cold model load on the first query after an Ollama restart) |
|
||||
| `app.nl-search.rate-limit.max-requests-per-minute` | `5` | Per-user rate limit |
|
||||
|
||||
### Upgrade the Ollama model
|
||||
|
||||
To switch to a newer model version (e.g. a future release of `qwen2.5`):
|
||||
|
||||
1. Update the model name in the `ollama-model-init` `command:` in `docker-compose.yml`.
|
||||
2. Remove the existing model volume to free the old weights:
|
||||
```bash
|
||||
docker volume rm familienarchiv_ollama_models
|
||||
```
|
||||
(In production the volume name is prefixed with the compose project: `archiv-production_ollama-models`.)
|
||||
3. Restart the stack:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
The `ollama-model-init` container pulls the new model weights on first start (~4–8 GB download depending on the model). The `ollama` inference server will not start until the pull completes (`condition: service_completed_successfully`).
|
||||
|
||||
> **`ollama_models` volume:** holds model weights only — fully reproducible by re-pull, no backup needed.
|
||||
|
||||
### Trigger a canonical import
|
||||
|
||||
The importer no longer parses the raw spreadsheet. It consumes the **canonical artifacts**
|
||||
|
||||
@@ -149,7 +149,26 @@ _See also [Chronik](#chronik-internal)._
|
||||
|
||||
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
|
||||
|
||||
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
||||
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or curated document journey published in the archive. Two subtypes: `STORY` (free-form prose linking `Person`s and attaching documents via `journey_items`) and `JOURNEY` (a *Lesereise* — an ordered sequence of `JourneyItem`s). Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
||||
|
||||
**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a document attachment or editorial note belonging to a `Geschichte` of either subtype. JOURNEY-type Geschichten use items for their ordered reading sequence; STORY-type Geschichten use items to attach referenced documents (no type guard is enforced at the application layer — both subtypes share this table). Either document-backed (`document_id IS NOT NULL`) or a note-only interlude (`note IS NOT NULL`). Ordered by `position` (step of 10; max 100 items per Geschichte). A DEFERRABLE UNIQUE constraint on `(geschichte_id, position)` allows atomic position swaps in the same transaction. A CHECK constraint ensures at least one of `document_id` or `note` is present. The FK to `documents` uses `ON DELETE SET NULL`, so deleting a document preserves the item (with `document_id = null`). See ADR-037.
|
||||
|
||||
**GeschichteView** (`GeschichteView`) `[internal]` — lean read-model record returned by `GeschichteService.getById()`. Contains `AuthorView` (id + displayName only — email not exposed) and a `List<JourneyItemView>` loaded via a separate query rather than a lazy collection.
|
||||
|
||||
**JourneyItemView** (`JourneyItemView`) `[internal]` — lean view record for a single `JourneyItem` surface, containing `id`, `position`, an optional `DocumentSummary`, and an optional `note`.
|
||||
|
||||
**DocumentSummary** (`DocumentSummary`) `[internal]` — lean document read-model used inside `JourneyItemView`. Contains title, date, senderName, receiverName, receiverCount, datePrecision — no tags or file storage info.
|
||||
|
||||
**Interlude / Zwischentext** `[user-facing]` — an editorial paragraph inserted between document items in a *Lesereise*. An interlude is a `JourneyItem` with `document_id IS NULL` and a non-empty `note`; its content is a plain-text string stored in the `note` column (not `body` or `text`). Visually distinguished by `--color-interlude-bg/border/label` CSS tokens and a `ZWISCHENTEXT` label. Interludes cannot have their note removed (removing the interlude deletes the entire item).
|
||||
_Not to be confused with a document item's optional note_ — a document item's note is curator commentary attached to a linked letter; an interlude is standalone editorial prose with no backing document.
|
||||
|
||||
**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience.
|
||||
|
||||
**TimelineEvent** (`TimelineEvent`, table `timeline_events`) `[internal]` — a curated event on the family timeline (*Zeitstrahl*), authored by a curator rather than OCR-derived. Carries the same date block as a `Document` (`eventDate` + `precision` + nullable `eventDateEnd`) so events and letters render through one path, plus a `title`, optional `description`, an `EventType`, and `ManyToMany` links to the `Person`s it involves and the `Document`s that support it (both join FKs `ON DELETE CASCADE`). Diverges from `Document` with an optimistic-lock `@Version` and a NOT NULL `createdBy`/`updatedBy` audit trail (bare UUIDs, no FK to `app_users`) for the multi-curator edit flow. Two DB CHECKs: `event_date_end` is non-null **iff** precision is `RANGE` (a strict biconditional, intentionally tighter than `Document`'s open-ended ranges), and `precision` is never `UNKNOWN` (a curated event always has at least a year; `SEASON`/`APPROX` stay legal). See ADR-040.
|
||||
|
||||
**EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040).
|
||||
|
||||
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s (and, in later issues, derived life-events) chronologically. The milestone home of the `timeline` domain.
|
||||
|
||||
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
|
||||
|
||||
@@ -165,23 +184,7 @@ _See also [Chronik](#chronik-internal)._
|
||||
|
||||
**Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`.
|
||||
|
||||
---
|
||||
|
||||
## NL Search Terms
|
||||
|
||||
**NlSearch** — the natural-language document search feature. Users type a plain-German query (e.g. "Was hat Walter im Krieg an Emma geschrieben?"); the backend parses it via Ollama, resolves person names to database UUIDs, and delegates to the standard `DocumentService.searchDocuments()` path. Endpoint: `POST /api/search/nl`.
|
||||
|
||||
**NlQueryInterpretation** — the structured result of parsing a natural-language query. Contains: `resolvedPersons` (persons whose names unambiguously matched one DB record), `ambiguousPersons` (all candidates when a name matched more than one person), `keywords` (LLM-extracted search terms), `dateFrom`/`dateTo` (extracted date range), `rawQuery` (the original user input), `keywordsApplied` (whether keyword FTS was used), `resolvedTags` (tags matched by keyword→tag resolution), and `tagsApplied` (whether the OR-union tag filter was applied).
|
||||
|
||||
**keyword→tag resolution** — the post-Ollama step in `NlQueryParserService` where each LLM-extracted keyword is substring-matched against the tag taxonomy via `TagService.findByNameContaining()`. Keywords that hit one or more tags are removed from the FTS text list and become an OR-union tag filter; keywords with no match remain as FTS text. Matching is case-insensitive and traverses the tag hierarchy via the recursive CTE `findDescendantIdsByName`. See ADR-033.
|
||||
|
||||
**PersonHint** — a lightweight `{id, displayName}` pair used in `NlQueryInterpretation` to describe a resolved or ambiguous person without exposing the full `Person` entity to the frontend.
|
||||
|
||||
**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer"). The vocabulary is deliberately match strength, not the search layer's resolved/ambiguous buckets — `NlQueryParserService` maps one direct → resolved (auto-select), ≥2 direct → ambiguous, partial-only → ambiguous suggestions ("Meintest du …?"), and no candidates → folded into full-text search.
|
||||
|
||||
**TagHint** — a lightweight `{id, name, color?}` triple used in `NlQueryInterpretation.resolvedTags` to describe a tag matched by keyword→tag resolution. `color` is the tag's effective color (one-level inheritance from parent when the tag has no own color), or null if neither tag nor parent has a color.
|
||||
|
||||
**theme chip** `[frontend]` — a removable chip rendered in `InterpretationChipRow` for each entry in `NlQueryInterpretation.resolvedTags` when `tagsApplied` is `true`. Displays "Thema: {tag.name}" (prefix varies by locale). Clicking × removes the tag from the OR-union filter and navigates to `/documents?tag=…&tagOp=OR` with remaining tag and person parameters preserved.
|
||||
**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer").
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# ADR-028 — Natural language search is powered by Ollama (Qwen 2.5 7B), not a cloud API
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Accepted
|
||||
**Issue:** #738 (NL search backend); part of epic #735
|
||||
**Milestone:** Archive Intelligence — NL Search
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Family members write their search intent in plain German ("Was hat Walter im Krieg an Emma geschrieben?"), not in structured filter forms. Issue #735 defines NL search as a core product goal. Three delivery options were evaluated:
|
||||
|
||||
**Option A — extend the OCR service.** The OCR Python microservice already runs on the same host. Adding LLM inference there avoids a new container. Rejected: the OCR service is a single-purpose, CPU-bound pipeline optimised for Kraken; bundling a 4.5 GB LLM weight into the same image would bloat it, complicate model lifecycle management, and create an unrelated failure domain (OOM on large OCR batches vs. LLM load time). ADR-001 was explicit about keeping OCR single-purpose.
|
||||
|
||||
**Option B — call an external API (OpenAI, Anthropic, etc.).** Cloud inference is instant and requires no local hardware. Rejected: the archive contains real person names and private family correspondence from 1899–1950 — sending query content to a third party violates the project's data-residency principle (family data stays on the family server). Additionally, API cost and availability are outside the operator's control; the system must work air-gapped.
|
||||
|
||||
**Option C — local Ollama service (chosen).** Ollama is a purpose-built LLM runtime with a simple REST API, model lifecycle management (`ollama pull`), and support for grammar-constrained JSON output. It runs entirely on the existing server (i7-6700, 64 GB RAM) with no cloud dependency.
|
||||
|
||||
**Model selection:** Qwen 2.5 7B Q4_K_M (`qwen2.5:7b-instruct-q4_K_M`) was chosen over larger models because:
|
||||
- Quantised weight is ~4.5 GB — fits comfortably in 64 GB RAM alongside PostgreSQL and the JVM.
|
||||
- Instruction-tuned variant follows the structured JSON schema reliably without fine-tuning.
|
||||
- CPU-only inference at Q4_K_M takes 2–15 seconds per query, acceptable for a search that replaces a multi-step filter form.
|
||||
|
||||
**Prompt injection mitigation:** The backend sends the raw user query to Ollama. To prevent the model from being prompted to return schema-breaking output, the API call uses Ollama's `format` parameter with a grammar-constrained JSON schema. Output length is further bounded by `maxLength` constraints in the schema (names ≤ 200 chars, keywords ≤ 100 chars). `NlQueryParserService` enforces these limits in code before any LLM-extracted fragment is passed to `PersonRepository.searchByName()` — defence in depth.
|
||||
|
||||
**DB-blind name resolution:** The Ollama prompt stays small (the raw query only); person database records are never sent to the model. Name resolution happens as a cheap SQL query after the model returns. This keeps the prompt short, avoids data leakage, and means adding 1,000 new persons requires no prompt change.
|
||||
|
||||
**Graceful degradation:** In-path Ollama failures surface via `OllamaClient.parse()` — any `IOException`, read timeout, or non-2xx response is caught by `RestClientOllamaClient` and re-thrown as `DomainException(SMART_SEARCH_UNAVAILABLE, HTTP 503)`. `isHealthy()` has no callers inside `search/`; it is reserved for the ops/health-endpoint polling path only (e.g. a future `/api/health/ollama` endpoint). The regular structured search (`GET /api/documents/search`) is unaffected — it never calls Ollama.
|
||||
|
||||
**Expected inference latency:** 2–15 seconds on the current CPU-only hardware. The frontend issue must show a persistent "Suche läuft…" indicator for the full duration (see `aria-live="polite"` requirement in issue #738 frontend notes). The backend timeout is 30 seconds (`app.ollama.timeout-seconds=30`) — chosen as a safe upper bound for Q4_K_M on the i7-6700 with a realistic 500-character query under modest concurrent load.
|
||||
|
||||
**NL query logging policy:** Only metadata is logged — query length, resolved person count, latency in milliseconds. The raw query is never written to the log file. Rationale: queries contain real family names (PII); log files persist to disk and may be shipped to Loki. Structured metadata is sufficient for debugging latency regressions.
|
||||
|
||||
**Prompt-amplification abuse:** A malicious user could submit a long or crafted query to cause slow Ollama inference, consuming CPU. Mitigated by `NlSearchRateLimiter` (5 requests per user per minute, Bucket4j + Caffeine) and by `@Size(max=500)` on the request body. The rate limiter is node-local; in multi-replica deployments the effective limit multiplies by replica count — acceptable at the current single-node deployment scale.
|
||||
|
||||
**Ollama model pre-pull requirement:** The Docker image contains only the Ollama binary, not the model weights. The operator must run `ollama pull qwen2.5:7b-instruct-q4_K_M` (≈4.5 GB download, 10–30 minutes) before the backend starts inference. If skipped, every NL search request returns 503 until the pull completes. The deployment runbook in `docs/DEPLOYMENT.md` covers this explicitly.
|
||||
|
||||
**Startup dependency:** The `backend` Compose service declares `depends_on: ollama: condition: service_healthy`. The Ollama healthcheck polls `GET http://localhost:11434/api/tags`; `start_period: 120s` provides margin for weight loading (20–60 s on SSD). Note: `service_healthy` confirms the API is responding, not that the model is downloaded — if the pull was skipped, inference still returns 404.
|
||||
|
||||
**Multi-name resolution heuristic:** For 2-name queries (e.g. "Was hat Walter an Emma geschrieben?"), the first extracted name is treated as sender and the second as receiver. Per-name role annotation (e.g. `{name: "Walter", role: "sender"}`) was rejected because it would require a combinatorially complex Ollama schema and the most natural German phrasing strongly implies sender→receiver order. For single-name queries, a `personRole` field (`sender`/`receiver`/`any`) is returned.
|
||||
|
||||
**`personRole: "any"` keyword limitation:** When `personRole` is `"any"` and the name resolves to exactly one person, `DocumentService.searchDocumentsByPersonId()` is called (OR semantics: person as sender or receiver). Keyword filtering is not applied on this path — only person identity and date range. `keywordsApplied = false` is returned in the response. Rationale: the JPQL for OR-semantics person queries has no text predicate; adding FTS would require a native query or a separate pass, adding complexity for a case that is already well-narrowed by person identity.
|
||||
|
||||
**`search/` → `person/` + `document/` dependency direction:** `NlQueryParserService` calls `PersonService.findByDisplayNameContaining()` and `DocumentService.searchDocuments()` — both are legitimate cross-domain service calls, not repository leaks. The `search/` package has no JPA entities of its own and never accesses `PersonRepository` or `DocumentRepository` directly.
|
||||
|
||||
**Keyword→tag resolution** (issue #743): After Ollama extracts the `keywords` list, `NlQueryParserService` calls `TagService.findByNameContaining()` for each keyword. Keywords that match one or more tags are removed from the FTS text list and added as OR-union tag filters; keywords with no tag match remain as FTS text. Resolved tags are returned to the frontend as `TagHint` objects in `NlQueryInterpretation.resolvedTags` and rendered as removable "Thema: X" chips. The `tagsApplied` flag signals whether the OR-union filter was actually passed to `DocumentService.searchDocuments()` — it is `false` when the `personRole:any` single-person path is taken, because that path has no tag filter slot. See ADR-033 for the tag name resolution and case-collision rules that `TagService.findByNameContaining()` relies on.
|
||||
|
||||
## Decision
|
||||
|
||||
**Introduce a new `search/` domain package** with a local Ollama integration via `RestClientOllamaClient`. The Ollama service runs as a separate Docker container, reachable only on the internal Docker network (`expose: ["11434"]`, not `ports:`). The backend calls Ollama's `/api/generate` endpoint with grammar-constrained JSON output. Name resolution and document search are performed by existing services after the model returns.
|
||||
|
||||
Key component structure:
|
||||
- `OllamaClient` / `OllamaHealthClient` interfaces — mockable for tests, modelled on `OcrClient`/`OcrHealthClient`
|
||||
- `RestClientOllamaClient` — two `RestClient` instances (30 s inference, 2 s health-check)
|
||||
- `NlQueryParserService` — orchestrates Ollama → name resolution → document search
|
||||
- `NlSearchRateLimiter` — Bucket4j + Caffeine, 5 req/min per user
|
||||
- `NlSearchController` — `POST /api/search/nl`, `@RequirePermission(READ_ALL)`
|
||||
|
||||
## Consequences
|
||||
|
||||
- Family members can query in natural German without learning filter UI. Expected search satisfaction improvement for the 60+ age cohort (primary transcription audience) is significant.
|
||||
- NL search is unavailable when Ollama is down or the model pull is not complete. The regular search is unaffected. The 503 response includes a CTA directing users to the regular search.
|
||||
- Operator responsibility: run `ollama pull` on first deploy and after model updates. The backup runbook must exclude `ollama_models` volume (model weights are re-downloadable, not user data).
|
||||
- Inference takes 2–15 seconds. The frontend loading indicator is a hard requirement (see issue #738 frontend notes).
|
||||
- The rate limiter is node-local. At the current single-node deployment scale this is correct. If the service is ever scaled horizontally, the rate limiter must be moved to Redis (same caveat as `LoginRateLimiter`).
|
||||
- The `search/` package introduces a new cross-domain dependency direction (`search` → `person`, `search` → `document`). This is intentional and documented in `docs/architecture/c4/l3-backend-search.puml`.
|
||||
@@ -1,239 +0,0 @@
|
||||
# ADR-028: Ollama Docker Compose service for NL search
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Accepted
|
||||
**Deciders:** Marcel Raddatz
|
||||
**Relates to:** #737 (infrastructure), #735 (NL search epic)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Issue #735 introduces natural-language document search, requiring a local LLM to generate embeddings and/or run inference at query time. The family archive stores personal family history — data privacy is non-negotiable, so cloud-based inference APIs are excluded. The production target is a Hetzner CX42 (16 GB RAM, 8 vCPUs, CPU-only, ~32 EUR/month).
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
| Option | Reason rejected |
|
||||
|---|---|
|
||||
| **llama.cpp** | No HTTP API out of the box; requires custom wrapper; higher ops burden |
|
||||
| **vLLM** | GPU-first; significant overhead on CPU-only hardware; overkill for this scale |
|
||||
| **Cloud APIs** (OpenAI, Gemini, etc.) | Vendor lock-in; per-token cost at scale; data leaves the server — unacceptable for a private family archive |
|
||||
| **Ollama** | Self-contained Docker image; built-in HTTP REST API; actively maintained; CPU-compatible; zero egress |
|
||||
|
||||
**Decision:** run Ollama as a Docker Compose service alongside the existing stack.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Hardware minimums and CPU-only constraint
|
||||
|
||||
All inference runs on CPU. The target is the Hetzner CX42 (16 GB RAM, 8 vCPUs).
|
||||
|
||||
| Tier | RAM | NL search |
|
||||
|---|---|---|
|
||||
| CX42 | 16 GB | Supported — full stack including Ollama |
|
||||
| CX32 | 8 GB | Disabled — set `APP_OLLAMA_BASE_URL=` (empty) to skip Ollama entirely |
|
||||
| CX22 | 4 GB | Unsupported for NL search |
|
||||
|
||||
### 2. Memory budget on CX42
|
||||
|
||||
| Component | `mem_limit` | Typical active RSS |
|
||||
|---|---|---|
|
||||
| OCR service | 12g (hard ceiling) | ~6 GB |
|
||||
| Ollama | 8g | ~8 GB |
|
||||
| **Total** | | **~14 GB active** |
|
||||
|
||||
`memswap_limit` on the Ollama service is set to `8g` (matching `mem_limit`) to prevent Linux from swapping model weights into swap under OCR memory pressure. Swapping model weights does not crash the container but silently degrades inference latency. This mirrors the pattern already applied to the OCR service.
|
||||
|
||||
**Operational constraint:** do NOT run `docker-compose.observability.yml` continuously alongside both OCR and Ollama on a CX42. The observability stack adds ~2 GB, which leaves no headroom.
|
||||
|
||||
### 3. Graceful-degradation contract
|
||||
|
||||
`app.ollama.base-url` absent OR blank → Ollama bean NOT registered → NL search returns HTTP 503 with `ErrorCode: NL_SEARCH_UNAVAILABLE`.
|
||||
|
||||
This single code path covers all unavailability scenarios: base-url unset, service unreachable, health check failed, and request timeout.
|
||||
|
||||
#### Why not `@ConditionalOnProperty`
|
||||
|
||||
`@ConditionalOnProperty` registers the bean when the property is present but blank (`APP_OLLAMA_BASE_URL=`). This produces a `RestClient` with an empty base URL that fails at runtime with an opaque error rather than a clean 503.
|
||||
|
||||
#### Correct condition expression
|
||||
|
||||
```java
|
||||
@ConditionalOnExpression("!'${app.ollama.base-url:}'.isBlank()")
|
||||
```
|
||||
|
||||
When the property is absent, the placeholder resolves to `''`; `.isBlank()` returns `true`; negation makes the condition `false`; the bean is not registered. Same result for an explicit empty string (`APP_OLLAMA_BASE_URL=`).
|
||||
|
||||
### 4. Backend configuration pattern
|
||||
|
||||
Use a `@ConfigurationProperties` record, not separate `@Value` injections:
|
||||
|
||||
```java
|
||||
@ConfigurationProperties("app.ollama")
|
||||
record OllamaProperties(String baseUrl, String apiKey) {}
|
||||
```
|
||||
|
||||
`OllamaProperties` is registered unconditionally — it is a plain value holder with no side effects.
|
||||
|
||||
`@ConditionalOnExpression` belongs **only** on `RestClientOllamaClient` (the bean that creates a live network client).
|
||||
|
||||
**Deliberate divergence from the OCR pattern:** the OCR service uses `@Value`-with-default because OCR is always-on and `http://ocr-service:8000` is a safe default. Ollama is truly optional — a missing URL means "feature disabled", not "use this default server". There is no safe default Ollama URL.
|
||||
|
||||
### 5. Optional<OllamaClient> injection
|
||||
|
||||
The NL search service uses constructor injection with `Optional<OllamaClient>`:
|
||||
|
||||
```java
|
||||
private final Optional<OllamaClient> ollamaClient;
|
||||
```
|
||||
|
||||
When empty (bean not registered), the service method returns 503 immediately:
|
||||
|
||||
```java
|
||||
var client = ollamaClient.orElseThrow(
|
||||
() -> DomainException.internal(ErrorCode.NL_SEARCH_UNAVAILABLE, "Ollama not configured"));
|
||||
```
|
||||
|
||||
Prefer this over `@Autowired(required = false)` with a null check — the null-check pattern is noisy when the service already uses `@RequiredArgsConstructor`.
|
||||
|
||||
### 6. Empty API key guard
|
||||
|
||||
`RestClientOllamaClient` omits the `Authorization` header entirely when `apiKey` is blank:
|
||||
|
||||
```java
|
||||
if (!apiKey.isBlank()) {
|
||||
request.header("Authorization", "Bearer " + apiKey);
|
||||
}
|
||||
```
|
||||
|
||||
Sending `Authorization: Bearer ` (empty token) has undefined or potentially broken behavior depending on the Ollama version. This mirrors the `trainingToken` guard in `RestClientOcrClient.java:107`.
|
||||
|
||||
### 7. OLLAMA_API_KEY behavior in Ollama 0.6.5 and 0.30.6
|
||||
|
||||
**Empirically verified (2026-06-06) on both `0.6.5` and `0.30.6`:** `OLLAMA_API_KEY` does **not** enforce request authentication in either version.
|
||||
|
||||
Test matrix run against `/api/tags`:
|
||||
|
||||
| Configuration | No auth header | `Authorization: Bearer ` (empty) | `Authorization: Bearer wrongkey` | `Authorization: Bearer correctkey` |
|
||||
|---|---|---|---|---|
|
||||
| `OLLAMA_API_KEY=` (empty) | 200 | 200 | — | — |
|
||||
| `OLLAMA_API_KEY` unset | 200 | — | — | — |
|
||||
| `OLLAMA_API_KEY=testkey99` | 200 | 200 | 200 | 200 |
|
||||
|
||||
**Finding:** The `OLLAMA_API_KEY` environment variable is not listed in Ollama's startup config dump and does not gate any HTTP request in either tested version. All configurations — empty string, fully unset, and a real key — accept all requests without authentication.
|
||||
|
||||
**Practical implication:** `OLLAMA_API_KEY` provides no defense-in-depth in the tested versions. `archiv-net` network isolation is the only effective security control. The env var is retained in the Compose definition and `.env.example` for forward compatibility if Ollama enables enforcement in a future version, but operators must not rely on it for access control.
|
||||
|
||||
**Backend guard still valid:** the `RestClientOllamaClient` code-level guard (omit `Authorization` header when `apiKey.isBlank()`) remains correct behavior regardless — it prevents a malformed `Authorization: Bearer ` header from being sent.
|
||||
|
||||
### 8. read_only: true feasibility
|
||||
|
||||
**Empirically verified (2026-06-06) on both `0.6.5` and `0.30.6`:** `read_only: true` works with Ollama. All three operations — `ollama serve`, `ollama pull qwen2.5:7b-instruct-q4_K_M`, and `ollama list` — succeeded with exit code 0 in both versions.
|
||||
|
||||
Test run:
|
||||
```bash
|
||||
docker run --rm --read-only \
|
||||
-v ollama_models:/root/.ollama \
|
||||
--tmpfs /tmp \
|
||||
--entrypoint sh ollama/ollama:0.30.6 \
|
||||
-c "ollama serve & sleep 5 && ollama pull qwen2.5:7b-instruct-q4_K_M && ollama list"
|
||||
```
|
||||
|
||||
**Note:** the entrypoint must be overridden to `sh` for the test command — the container's default entrypoint is `/bin/ollama` and does not accept `sh` as a subcommand. This is a Docker invocation detail; the Compose service definition uses the image's default entrypoint and `command:` override for the init container, which works correctly.
|
||||
|
||||
**Result:** `read_only: true` and `tmpfs: - /tmp:size=512m` are applied to both `ollama` and `ollama-model-init`. The `ollama_models` volume handles all persistent writes; no other paths require write access during normal operation.
|
||||
|
||||
### 9. Peak RSS of init container during pull
|
||||
|
||||
**Empirically verified (2026-06-06):** Peak RSS during `qwen2.5:7b-instruct-q4_K_M` pull was **~108 MiB**.
|
||||
|
||||
`docker stats` samples during the pull (15-second intervals):
|
||||
|
||||
| Sample | MEM |
|
||||
|---|---|
|
||||
| 1 | 54.89 MiB |
|
||||
| 2 | 66.3 MiB |
|
||||
| 5 | 97.25 MiB |
|
||||
| 9 | **107.8 MiB** (peak) |
|
||||
|
||||
`mem_limit: 2g` is adequate — the model weights stream directly to the named volume; RSS is dominated by the Ollama server process alone (~100 MB), not the model data. No bump to 4 GB needed.
|
||||
|
||||
### 10. Init container pull mechanism
|
||||
|
||||
The `ollama-model-init` container uses a curl-based readiness loop with captured PID:
|
||||
|
||||
```sh
|
||||
ollama serve & SERVE_PID=$!
|
||||
until curl -sf http://localhost:11434/api/tags; do sleep 1; done
|
||||
ollama pull qwen2.5:7b-instruct-q4_K_M
|
||||
kill $SERVE_PID
|
||||
```
|
||||
|
||||
`kill %1` (job-control syntax) is unreliable in non-interactive `sh -c` contexts. Capturing the PID via `SERVE_PID=$!` is reliable.
|
||||
|
||||
The same endpoint (`/api/tags`) is used for both the init container readiness loop and the main service `healthcheck`.
|
||||
|
||||
### 11. start_period: 60s rationale
|
||||
|
||||
The model is pre-pulled by `ollama-model-init` before the main service starts (via `condition: service_completed_successfully`). At main service startup, Ollama only loads model weights from the named volume and binds port 11434.
|
||||
|
||||
60 seconds is appropriate for this cold-start profile. 300 seconds was considered — that would be appropriate if the service pulled the model itself — but overstates actual startup time when the model is already present on the volume.
|
||||
|
||||
### 12. Security threat model
|
||||
|
||||
**Primary control:** `archiv-net` network isolation. Ollama has no externally exposed port (`expose:` only, not `ports:`). The Caddyfile must not route any path to the Ollama service.
|
||||
|
||||
**Note on `OLLAMA_API_KEY`:** Per §7, `OLLAMA_API_KEY` is not enforced in Ollama 0.6.5 or 0.30.6 and provides no authentication barrier against a compromised backend container. `archiv-net` network isolation is the sole effective security control. The env var is retained for forward compatibility only — do not rely on it for access control.
|
||||
|
||||
Both `ollama` and `ollama-model-init` receive the ADR-019 hardening baseline:
|
||||
|
||||
```yaml
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
```
|
||||
|
||||
### 13. CI exclusion strategy
|
||||
|
||||
Docker Compose profiles are not used — they would add developer friction (requiring `--profile ...` for all local dev commands).
|
||||
|
||||
CI uses explicit service selection in `docker-compose.ci.yml`:
|
||||
```bash
|
||||
docker compose -f docker-compose.ci.yml up -d db minio create-buckets
|
||||
```
|
||||
|
||||
Ollama is simply not listed and is never started in CI. A YAML comment on the `ollama` service block documents this:
|
||||
|
||||
```yaml
|
||||
# Not started in CI — CI uses explicit service selection
|
||||
# (docker-compose.ci.yml: db minio create-buckets)
|
||||
```
|
||||
|
||||
### 14. ollama_models volume operational note
|
||||
|
||||
The `ollama_models` named volume holds model weights only — fully reproducible by re-pull. No backup is needed.
|
||||
|
||||
If the volume fills after a model upgrade:
|
||||
```bash
|
||||
docker volume rm ollama_models && docker compose up -d
|
||||
```
|
||||
The init container re-pulls the model on next startup.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- NL search runs entirely on-premises; no data leaves the server and no per-token cloud cost.
|
||||
- Graceful degradation is a first-class concern: smaller or budget-constrained instances can run the app without Ollama with a single env var change.
|
||||
- The init container pattern keeps model pull out of the critical startup path for the main service, giving accurate healthcheck timings.
|
||||
- `@ConditionalOnExpression` with a blank-check is more correct than `@ConditionalOnProperty` for optional features with no safe default URL.
|
||||
|
||||
### Risks and operational implications
|
||||
|
||||
- **Memory pressure:** OCR + Ollama together consume ~14 GB on a 16 GB host. Running the observability stack simultaneously risks OOM kills. Monitor with `docker stats`.
|
||||
- **CPU inference latency:** `qwen2.5:7b-instruct-q4_K_M` is chosen for CPU viability, but inference on 8 vCPUs will be noticeably slower than GPU-accelerated alternatives. This is acceptable for the family archive use case (low concurrency, not real-time).
|
||||
- All three empirical TBD items from the original issue spec were resolved — see §7 (OLLAMA_API_KEY not enforced), §8 (`read_only: true` works), §9 (peak RSS ~108 MiB).
|
||||
- Model upgrades require a `docker volume rm` to free old weights before pulling the replacement. Document this in runbook/DEPLOYMENT.md.
|
||||
@@ -1,125 +0,0 @@
|
||||
# ADR-034: Ollama in production — deployment, keep-alive pinning, and corrected init recipe
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Status:** Accepted
|
||||
**Deciders:** Marcel Raddatz
|
||||
**Relates to:** #758 (bug), #759 (fix), #737 (NL search infrastructure)
|
||||
**Corrects:** ADR-028 §10–§11 (init recipe and readiness probe)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-028 introduced Ollama as a Docker Compose service for NL search and documented
|
||||
its topology, graceful-degradation contract, and memory budget. Two defects survived
|
||||
that work and only surfaced once NL search reached staging (#758):
|
||||
|
||||
1. **Ollama was added only to the dev `docker-compose.yml`.** Staging and production
|
||||
deploy from the self-contained `docker-compose.prod.yml`, which had no `ollama`
|
||||
service. The backend defaults to `app.ollama.base-url: http://ollama:11434`, so its
|
||||
client bean was active and resolved to a non-existent host → `ResourceAccessException`
|
||||
→ HTTP 503 on every NL search.
|
||||
2. **The init recipe documented in ADR-028 §10 never worked.** The `ollama/ollama` image
|
||||
`ENTRYPOINT` is `ollama`, so a bare `command: sh -c "…"` ran as `ollama sh -c "…"`
|
||||
(`unknown command "sh"`), and the image ships **no curl**, so the curl-based readiness
|
||||
loop and the curl healthcheck could never pass.
|
||||
|
||||
This ADR records the production deployment decision and the corrected operational
|
||||
contract. It is also the durable record of *why* `OLLAMA_KEEP_ALIVE=-1` is set, so a
|
||||
future maintainer does not "optimize" it away and reintroduce the cold-load 503.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Ollama is a first-class production service
|
||||
|
||||
`docker-compose.prod.yml` now defines `ollama` + `ollama-model-init` + the
|
||||
`ollama-models` volume, mirroring the dev stack. The graceful-degradation contract from
|
||||
ADR-028 §3 is preserved: `backend` has **no** hard `depends_on` on `ollama`, so an absent
|
||||
or unhealthy Ollama still yields a clean 503 rather than blocking backend startup.
|
||||
|
||||
### 2. Corrected init recipe (supersedes ADR-028 §10)
|
||||
|
||||
The init container overrides the image entrypoint to a shell and probes readiness with
|
||||
`ollama list` (not curl, which the image lacks):
|
||||
|
||||
```sh
|
||||
ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && \
|
||||
(ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)
|
||||
```
|
||||
|
||||
```yaml
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
```
|
||||
|
||||
The pull is **guarded by a grep on the cached model list**. A model already on the volume
|
||||
exits clean without any registry round-trip. This makes re-up offline-safe: a host reboot
|
||||
during a registry/network blip can no longer fail init (which, via
|
||||
`condition: service_completed_successfully`, would otherwise block the `ollama` service
|
||||
and take NL search down until the registry was reachable again). The same recipe is used
|
||||
in dev and prod — one mental model.
|
||||
|
||||
### 3. Healthcheck uses `ollama list` (supersedes ADR-028 §11 probe)
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test: ["CMD", "ollama", "list"]
|
||||
```
|
||||
|
||||
`ollama list` hits the local API and exits non-zero when the server is down — the correct
|
||||
probe for a curl-less image. The `start_period: 60s` rationale from ADR-028 §11 still holds.
|
||||
|
||||
### 4. `OLLAMA_KEEP_ALIVE=-1` — pin the model in memory
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
OLLAMA_KEEP_ALIVE: "-1"
|
||||
```
|
||||
|
||||
By default Ollama evicts an idle model after ~5 minutes. The next query then pays a
|
||||
cold-load penalty that exceeds the backend read timeout, producing an NL search 503 after
|
||||
any idle period. Pinning the model (`-1` = never unload) keeps warm-path latency
|
||||
predictable (~18 s on CPU). **Do not remove this** without re-introducing the post-idle
|
||||
cold-load 503.
|
||||
|
||||
### 5. Read timeout raised 30 → 60 s
|
||||
|
||||
`app.ollama.timeout-seconds` is raised from 30 to 60 (`application.yaml`, mirrored in
|
||||
`DEPLOYMENT.md`). Warm CPU inference is ~18 s; the higher ceiling absorbs the one cold
|
||||
model load on the first query after an Ollama (re)start, before §4's pin takes hold.
|
||||
|
||||
**Implicit NFR made explicit:** NL search shall return a result or a 503 within 60 s; the
|
||||
cold-start path immediately after an Ollama restart is the only path that approaches this
|
||||
ceiling.
|
||||
|
||||
### 6. Hard-OOM trade-off (refines ADR-028 §2)
|
||||
|
||||
`memswap_limit == mem_limit` (both `${OLLAMA_MEM_LIMIT:-8g}`) disables swap for the
|
||||
container. Combined with §4's pinned model, a memory-pressure event is a **hard OOM-kill,
|
||||
not graceful latency degradation**. This is deliberate — swap-thrashing an LLM is worse
|
||||
than a clean restart — but it means the 8 GB envelope is a real ceiling. `qwen2.5-7B-q4`
|
||||
plus its KV cache under load sits close enough to 8 GB that this needs a Prometheus
|
||||
memory alert on the `ollama` container before it bites in production (tracked as
|
||||
observability follow-up, not in this PR).
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- NL search works on staging/production, not just dev — the actual deploy artifact now
|
||||
matches the documented architecture.
|
||||
- Re-up is offline-safe: a cached model never depends on registry reachability.
|
||||
- The keep-alive pin and timeout ceiling make NL search latency predictable on CPU.
|
||||
|
||||
### Risks and operational implications
|
||||
|
||||
- **Hard OOM under memory pressure** (§6): a Prometheus alert on `ollama` container memory
|
||||
is required before this is load-bearing in prod. Tracked as an observability follow-up.
|
||||
- **Unauthenticated inference** relies entirely on `archiv-net` isolation (ADR-028 §7/§12,
|
||||
unchanged). Sending an `Authorization` header from `RestClientOllamaClient` is a separate
|
||||
durable hardening item, tracked outside this PR.
|
||||
- ADR-028 §10–§11 describe a recipe that never functioned; this ADR is the authoritative
|
||||
init/healthcheck contract going forward.
|
||||
53
docs/adr/034-remove-nl-search.md
Normal file
53
docs/adr/034-remove-nl-search.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# ADR-034 — Remove NL/smart-search (supersedes ADR-028 ×2, ADR-034-ollama, ADR-035)
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Accepted
|
||||
**Issue:** #772
|
||||
**Supersedes:** ADR-028 (nl-search-ollama), ADR-028 (ollama-docker-compose-service), ADR-034 (ollama-production-deployment-and-keep-alive), ADR-035 (rule-based-nlp-service)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The natural-language search feature ("KI-Suche" / smart search) allowed users to enter
|
||||
free-form queries like *"Was hat Walter an Emma im Krieg geschrieben?"* and have them
|
||||
interpreted by an LLM into structured filters (persons, tags, date range, keywords).
|
||||
|
||||
The feature went through two major iterations:
|
||||
1. **Ollama integration** (ADR-028): an `ollama` Docker service running a local LLM
|
||||
(llama3.2/gemma3) parsed queries via a JSON-mode prompt.
|
||||
2. **Rule-based NLP service** (ADR-035): after Ollama proved too slow and unreliable on
|
||||
CPU-only hardware, a Python FastAPI microservice (`nlp-service`, port 8001) replaced
|
||||
it with deterministic regex + spaCy parsing plus a lightweight LLM call.
|
||||
|
||||
Both approaches shared the same fundamental problem: inference on the production server
|
||||
(Hetzner Serverbörse, no GPU, 64 GB RAM, i7-6700) was too slow to be useful, with
|
||||
typical query latencies of 10–30 seconds. Users got better and faster results from
|
||||
the existing keyword search with date/person/tag filters.
|
||||
|
||||
## Decision
|
||||
|
||||
**Remove the NL search feature entirely.** The Python `nlp-service` microservice, the
|
||||
Spring Boot `search/` package (`NlSearchController`, `NlQueryParserService`,
|
||||
`RestClientNlpClient`, `NlSearchRateLimiter`, and all supporting classes), the frontend
|
||||
NL search components (`SmartModeToggle`, `SmartSearchStatus`, `InterpretationChipRow`,
|
||||
`DisambiguationPicker`), the related Docker Compose services, Prometheus scrape job,
|
||||
Grafana dashboard, and all i18n keys are removed.
|
||||
|
||||
The existing structured search (FTS keyword + person/tag/date/directional filters) is
|
||||
sufficient for the archive's current audience and search workload.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Capability removed:** users can no longer enter free-form natural-language queries.
|
||||
They must use the structured filter bar (keyword text box + person/tag/date/directional
|
||||
dropdowns). For documents where these filters are sufficient, there is no regression.
|
||||
- **Operational simplification:** the Docker Compose stack loses two services
|
||||
(`nlp-service` and previously `ollama`/`ollama-model-init`). Memory budget on the
|
||||
production host is freed. No external model weights need to be kept warm.
|
||||
- **Future reinstatement:** if a GPU-capable host becomes available, re-implementing
|
||||
server-side LLM inference would be straightforward given the clean separation of the
|
||||
`NlSearchController` entry point. However, this ADR deliberately avoids leaving dead
|
||||
infrastructure or stub code in place — start clean if and when that becomes viable.
|
||||
- **No data or schema change:** only query/endpoint code and Docker services are removed.
|
||||
The `documents`, `persons`, and `tags` tables and their FTS indexes are untouched.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user